Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 2716562139 Edit the selected hunk from the focused main view
The staging view can hand the hunk you are on to an editor and apply what
comes back, which is how you stage something the diff cannot express: half
of a changed line, or a change written differently from either side. The
focused main view has to be able to do the same before that view can go.

The hunk is git's own, context and all, rather than lazygit's block of
adjacent changes: an editable patch is one that still applies, and the
context lines are what let git place it. What the editor leaves behind is
applied whole rather than matched against the file's diff again, the point
being that it says something the diff didn't.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 4a0defc412 Don't refresh while the editor still has the hunk
Editing a hunk points an editor at a patch we wrote to a temp file and
waits. Nothing about the repo has changed when the editor returns: the
change lands when the caller applies what came back. But the suspend path
this went through refreshes as soon as the subprocess exits, so it reads
the state from before the patch is applied and then races the caller's own
refresh to publish it. Whichever lands last wins, and when it is the stale
one the files panel goes on showing the file as it was before the edit
until something else refreshes.

The caller is the one that knows when there is something new to see, and
both callers already refresh once they have applied the patch, so the
refresh in the middle only ever had a wrong answer to give.

Being a publish-order race, it doesn't reproduce reliably enough for a
test; holding a background refresh between its read and its publish shows
it every time.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 07ca84c940 Ask a parsed patch which of its lines a selection covers
Staging a selection walks the file's diff to find the lines the user
pointed at, by where each of them sits in the file. The next commit needs
the same answer for a single row, so pull the walk out of the staging path
before there are two copies of it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 54465f6cbb Hold input back until the selection has moved on
Two space presses in quick succession only staged one hunk. Input is
withheld until the refresh has landed, which is enough where the diff is
rebuilt on the spot, but the main view re-renders asynchronously: the
selection only moves to the next change once that render is on screen, so
the second press acted on lines that were no longer in the diff, and
staged nothing.

So the wait is now for the selection to be where the work carries on from,
rather than for the model. A restore therefore has to say when it is done —
which it can be either way, since a view given a message rather than a
re-render now gives up the restore it was holding instead of leaving it to
claim some later render.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan Haller ce8dc1dc21 Replay withheld keys on a later pass of the event loop
EndBlockingEvents dispatches the keys buffered during a block from inside its
own call, so a keybinding handler runs in the middle of whatever the caller was
doing. If a caller ends the block partway through updating the screen, that
handler acts on state the caller has yet to finish writing.

No caller does that today; both of them end their block from a UI-thread
callback of their own, with nothing left to do afterwards. The commit after this
one adds a caller that can. It ends the block from a render restore, and a
render of the two main panes resolves that restore halfway through laying the
panes out.

Queue the replay through Update instead, so the buffered keys arrive on a later
pass of the loop, as they would have if the user had pressed them then. Input
stays withheld until that pass runs. Gui events are dispatched in preference to
queued work, so a key pressed in between would otherwise be handled ahead of the
keys buffered before it.

The replay's error now reaches the error handler along with every other
handler's, and EndBlockingEvents has nothing left to return.
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 2e494fad40 Cover the cases where the next change isn't the obvious one
Three cases where landing on the change that took the place of the one acted
on is not the same as landing on the next line, or on the same line number:
staging an inserted line moves every later line of the file, so the hunk
below it is somewhere else afterwards; consecutive deletions all sit at the
same place in the new file, so nothing but their order tells them apart; and
unstaging half of a modification carries on in the pane the staged side has,
which is where the work was already.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 8d1333656f Show a selection only over the diff the panel offers
Some merge conflicts can only be resolved by picking a side. The files
panel explains those rather than diffing them, and where one side deleted
the file and the other modified it, git's diff of that modification is
shown below the explanation. The focused main view took those change lines
for a diff of its own. It drew a selection over them and offered to stage
hunks of a file whose conflict staging can't resolve.

So have a render say whether it holds the diff the panel offers in the
main view, and put a selection only on one that does. Every diff render
already goes through NewMainViewDiffTask, so it says so for itself; the
custom patch preview, assembled as text rather than run as a command, says
so through NewMainViewDiffStringTask. Establishing a selection asks the
pane the same question rather than looking for change lines itself.

The selection has been wrong over this content since "Show a selection in
the focused main view" introduced it, and the fix belongs there. It lands
here instead because a render had no way to say what it holds until "Show
git's own diff when the renderer's can't be acted on" gave every diff
render one constructor to go through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 74aa1a05ac Show git's own diff when the renderer's can't be acted on
A diff renderer may lay a diff out however it likes: line numbers in a
gutter, the +/- column replaced by colour, the two sides in columns. Once
it has, we can only tell which line of which file a row shows if the
renderer says so. Under a renderer that doesn't say, the main view holds
a diff that can be read but not staged, edited or copied from. That is no
good now that the main view is where you stage.

So focusing it brings git's own diff instead, and every re-render while it
stays focused keeps to that, so staging a hunk doesn't flip back. Browsing
is untouched: you see what the renderer produced until you focus the view
to act on it.

Whether the renderer says anything is settled by asking it rather than by
watching it work: run it on empty input and see whether it announces the
protocol. Announcing is a property of the renderer, so the answer is known
before we render anything, and a diff with no lines to describe can't fool
it. Watching would have to see a diff go by first, and a binary file's diff
holds nothing that would tell the two cases apart. The answer is remembered
until the renderer changes. git itself is asked the same question, with the
renderer's own arguments, since it announces itself for exactly the formats
whose output can't be read back as a diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 104ebaa612 Say what a diff command's output is for
Two things about a diff command follow from what its output is for: whether
the diff renderer produces it, and whether it is coloured. That was a
`plain` flag, which covers two of the three cases — the diff as configured,
and git's own uncoloured diff for building patches out of — and leaves no
room for the third, which is about to be needed: git's own diff, coloured,
for showing where the renderer's version of it can't be acted on.

So the flag becomes a mode. It also takes over deciding the colour, which
each command spelled out for itself, and it settles a question the flag
couldn't put: whether ignoring whitespace applies. It is about what the user
wants to see, so it holds for anything shown, and not for a diff a patch is
built from, which has to describe every change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 473eb43e6c Commit and find a fixup base from the focused main view
Staging in the main view leaves you looking at the working tree's diff with
everything you meant to stage in the index — and, until now, having to go
back to the files panel to commit it. The commit keys, and the one that
finds the commit a fixup belongs to, are offered there too, so that the
whole staging-to-committing round happens in one place.

They act on the working tree, which the main view only sometimes shows, so
they do nothing over a commit's or a stash's diff — browsing history can't
commit by accident — and are listed only where they do something. The check
happens per press: what the main view is showing changes as the user moves
about, while the bindings are registered once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 d8cd1aa215 Discard the selected diff lines from the focused main view
The remove key now works on a diff selection the way it does in the staging
view: on the unstaged side it throws the selected lines away, which it asks
about first, and on the staged side it takes them out of the index, which is
unstaging and needs no warning.

It goes through the same path as staging, so that everything around the
action behaves identically — the selection lands on the change that took the
place of the discarded one, and the focus follows the side it acted on.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 2eee18ea74 Carry the acted-on lines into the pane the work lands in
Each side of a file's diff has a pane of its own, but a pane is only shown
while its side has something in it: staging the last unstaged change takes
the upper pane away, and unstaging the last staged one takes the lower one
away. The focus follows into the pane that is left, and the selection has to
be waiting there when it arrives — on the lines just acted on, which is where
they are now, unless they were discarded rather than moved, in which case on
what is left of the file. The pane being moved to shows no selection until
the restore places one, so that the selection it was left with the last time
it was used doesn't appear for a frame.

Whether the acted-on side still holds anything is a question the model can't
answer: a refresh only queues its update, and by the time it lands the
re-render this has to ride is already under way. So the answer is worked out
from what we just did — the files we changed report whether the selection
covered all of their changes, and the ones it didn't touch are as the model
describes them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 eddb1d5236 Leave a pane that only the permanent split keeps around
Configured to always split the diff, a pane is shown whether or not its side
of the file holds anything, so emptying the side the focus is on no longer
takes the pane away — but it does take away everything there was to do there,
which is the question the focus is really asking.

So the render says which of the panes it is giving something to act on,
rather than the focus reading that off which panes are shown.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 472aa1a9aa Stop a render restore saying whether it placed the view
Nothing has read that since the paint started settling the scroll position
before consulting the restore: what the answer was for was deciding whether
the reset the new content was owed still had to happen, and by then it has.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 9782c4abfd Follow the focus into the pane that is left
Each side of a file's diff has a pane of its own, and a pane is only shown
while its side has something in it. So anything that empties the side the
focus is on takes that pane away with it: committing what was staged, or a
commit or a discard happening outside lazygit and arriving with a refresh.
The focus was left on a pane that isn't there any more, where the next
keypress acted on nothing.

The render is what decides which panes are shown, so the question is asked
there, of every render rather than of the handful of actions that could think
to ask it themselves. The pane the focus moves into gets its selection once
that render has finished and there is something to put one on, the way
focusing it by hand would — and shows none until then, so that the selection
it was left with the last time it was used doesn't appear for a frame.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 7df7bc6276 Give the focused main view's selection a home outside the controllers
Where the selection starts out, and how it widens to a whole change block,
are questions about what the view is showing — the same rendered diff the
queries next door read. Nothing about them belongs to a keybinding, and the
render funnel is about to need them too, from a layer that can reach a helper
but not a controller.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 4146c52f4f Carry the selection to the next change after staging
Staging takes the lines it acted on out of the diff, so the selection has
nothing to sit on afterwards and would be left wherever those lines used to
be. What the user wants is the change that moved up into their place, so
that pressing the key again goes on to the next one — which is how staging
line by line through a file works.

The line acted on is gone, so it can't be remembered by identity the way a
re-render of the same diff remembers one; what is remembered instead is its
place in the sequence of the diff's changes, which the change after it
inherits. Staging the last change is the one case with nothing to inherit
it, and there the selection stays on the last change there is.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 a677104871 Extract the shared core of the diff-line restores
Putting a view back on a remembered line as it re-renders is two things: the
plumbing that watches the content arrive, reveals it at the right moment and
places the view, and the search that says which row to land on. Only the
second is specific to what is being remembered, and a second kind of it is
about to arrive — the change line an action leaves the selection on, which
is found by counting rather than by identity.

Behaviour-preserving.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 1d5ff34a71 Act on a whole file's changes as acting on the file
The diff of a deleted file is its content going away, so putting every
line of it into the index leaves an empty file there (modified in the
index, deleted in the working tree) rather than the deletion the user
selected. The reverse case matches: the diff of an added file is its
whole content, and taking all of it back out of the index leaves the file
tracked and empty rather than untracked again. In the staging view you
had to enter such a file deliberately to reach these cases, but stepping
through a directory's diff hunk by hunk runs into them routinely.

Selecting every change of a file says "this file", so stage or unstage
the file itself. This applies to any file, since applying a file's whole
diff amounts to the same thing everywhere else.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8e0a02a3b8 Stage and unstage diff lines from the focused main view
Space in the focused main view now acts on the selected lines the way it
does in the staging view: over the working tree's unstaged changes it puts
them into the index, and over the staged ones it takes them back out. Which
of the two it does is a property of the pane, each side of the diff having
one of its own. Nothing has to be entered first, and a selection reaching
across several files of a directory's diff is applied as one patch per file.

The rows on screen are only a picture of the diff, so the patch is built
from the diff itself: each selected row's identity — which file, which line
of it, and whether it is a deletion — is looked for in the file's own diff,
and the lines that match are the ones the patch includes. Matching by
position rather than by counting rows is what tells the two halves of a
modified line apart, since the deletion and the addition replacing it sit at
the same place in the new file.

Panels other than the working tree offer no action on their diff yet, so
space says nothing and does nothing there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 38945aa11e Leave a command with nothing to say out of the options bar
A command that acts on a diff selection has nothing to say over content that
isn't a diff, and says so by describing itself as nothing — but a binding
that is displayed on screen is displayed whatever its description, so it
would show as a key with an empty label. Leave it out instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 b6fa82c7ac Copy the selected diff lines from the focused main view
The main view shows a diff renderer's picture of a diff, and that is not
what you want on your clipboard: a renderer may drop the +/- column, move
the line numbers into a gutter, group a hunk's deletions before its
additions, or leave lines out altogether. So copying takes the lines from
the diff itself, locating them by the identity of the selected rows.

Only the panel that produced the diff can produce it again, though: the
working tree's staged or unstaged side, a commit's, a stash entry's. The
new seam is there for that. It asks per file, so that copying three lines
of a commit's diff doesn't fetch the whole of it, and it will grow the
actions on a selection as staging and patch building arrive.

The clipboard gets the run of diff lines from the first selected line of
a file to the last, so that lines the renderer hid come along and the
result still reads as a diff. Headers count as selected lines too. A hunk
header names the first line of its hunk, so it is looked for the way a
line of the file is. A file header names no line at all, and a rendering
may draw it over any number of rows, so a selection touching one of them
takes the whole header.

As in the patch explorers, a selection that is all additions or all
deletions loses its +/- column, ready to be pasted into code. One that
reaches into a header keeps the columns, and what comes out is a patch
fragment rather than lines of code.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 1cd8a1e7c2 Share how a ref's diff endpoints are derived
The commit files context works out the two ends to diff from the ref (or
range of refs) it was entered for. The panels that hold those refs
themselves — commits, sub-commits, stash, reflog — are about to need the
same two ends, to hand out the diff behind what they render into the
focused main view. Pull the derivation out of the context so they can ask
for it rather than each spelling out the parent-of-from rule again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 937f8f09db Always show a file's staged changes in the lower pane
Which pane a side of a file's diff appeared in depended on what else the file
had: the staged side had the lower pane while there were unstaged changes
above it, and took over the upper one when there weren't. So the same content
moved about, and which side a pane was showing was something the code had to
work out from the file's status rather than knowing from the pane.

Now each side has a pane of its own — unstaged above, staged below — shown
when there is something on it. A file with nothing unstaged shows its staged
changes in the lower pane alone, which then has the whole space, including
the label of the key that focuses it.

Nothing about this is visible to the user: the same diff appears in the same
place, with the same title and the same label, and the same keys focus and
scroll it. What it is for is the code, which no longer has to ask the file
what a pane is showing — and, once the diff can be staged from, no longer has
to make one key mean opposite things in the same pane.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 0dfd7ec1e4 Let the main section show the secondary pane alone
The main section shows one pane or two, which was enough while the second
pane only ever accompanied the first. It is about to have to show the second
one by itself: the working tree's staged changes are moving there for good,
and a file with nothing but staged changes has only that side to show — it
should have the whole section rather than sit under an empty pane.

So which panes are shown becomes a three-way answer, derived from which of
them the render has content for.

Behaviour-preserving: nothing renders into the secondary pane alone yet.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 6b51922bb9 Let an emptied main pane forget what it was showing
A pane that has been emptied is showing nothing, but it kept the scroll
position it was left at and went on claiming the render it used to show,
so the next render into it — the same command's output, the file it was
showing being selected again — counted as content the view already had,
and was revealed partway down.

So say what the empty pane is: at the top, and showing nothing that a
render can be a re-render of.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 ce6b293107 Add a test for a main pane coming back after being emptied
A pane the render has nothing to show is emptied, but keeps the scroll
position it was left at and its claim to the render it was showing. So
when it comes back — the file it was showing before is selected again —
its content is rendered under the same command it already had, which is
taken for the content the view is showing, and the user is left partway
down a diff they have only just been given.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8f25e160e8 Keep both ends of a selected range across a re-render
A range or hunk selection covers a stretch of the diff, and restoring only
the cursor left the other end pointing at whatever line the new rendering
happened to put there — with more context lines above, a selected hunk
would grow a tail of context it never covered.

So the other end is remembered by identity too, and put back before the
cursor. If it is a line the re-render dropped, the selection is left as the
single line we landed on: there is no telling which line inherits a
selection's edge, and a wrong guess acts on lines the user never chose.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 b49aaef0db Index a rendering by the diff lines it shows
Prep: pull the index out of the candidate search, so that looking up a
single diff line doesn't have to phrase itself as a search for the nearest
survivor among one candidate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cb040e3532 Keep your place in the diff when ignoring whitespace
Ignoring whitespace is how you ask whether what you are looking at is more
than reindentation, so being dropped at the top of the diff is a poor
answer: you have to find your way back to the change you were asking about
to see what became of it.

Unlike the other ways of re-rendering a diff, this one can take the line
you were on away for good, along with the hunk or even the file it was in.
When it does, the view lands on the nearest line the diff kept, wherever in
it that is — the walk of fallbacks doesn't stop at the file's edge, since a
file of nothing but reindentation leaves nothing nearer to land on. And
when ignoring whitespace empties the diff altogether, there is nothing to
keep and the view simply shows what is left.

A whitespace-only change that shares a hunk with a real one is a happier
case: it is shown as a context line rather than as a change, but it is
still the same line of the same file, so we stay on it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8aa1d93089 Keep your place in the diff when switching diff renderers
Cycling through the diff renderers is for comparing how they show the same
change, which is hard to do from the top of the diff each time. The line
you were on is the same line of the same file whichever renderer draws it,
so the restore finds it again — by the records a renderer states, where it
speaks the protocol, and by parsing its output as a diff otherwise.

The restore sits in DiffHelper.RenderToMainAgain. A switch between a
dark and a light terminal background renders the diff again through the
same helper, so it keeps your place as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 60666bdc30 Keep your place in the diff when changing the context size
Pressing { or } re-rendered the diff with less or more context around each
change and dropped you back at the top of it, so finding your way back to
what you were reading was on you.

Remember where the view is before triggering the re-render, and have the
restore put it back there. The line to remember is the selected one when a
selection is showing, and the middle visible line otherwise — what you are
looking at, rather than the view's top edge. It is remembered by identity,
because the new rendering puts it on a different line of the view, and it
is found again by the records a diff renderer states for its rows, or by
parsing the rendering as a diff where it still looks like one.

The line may not be there at all afterwards: shrinking the context takes
context lines away. So the lines around it come along as fallbacks, nearest
first, and the view lands on the nearest one that survived, put back on the
screen row it was on — leaving what you were reading where it was, give or
take the line that went. The walk that collects them covers the whole diff
rather than stopping at the nearest change on either side: those always
survive a context-size change, but not everything a re-render can do to a
diff is that considerate.

Under a diff renderer that says nothing about its rows there is nothing to
look for at all, and the view is left at the offset it had instead: often not
the line it was on, but always closer to it than the top of the diff. That is
what the re-render asks for besides the restore, since it is a different
command from the one that produced what is on screen and would otherwise be
taken for a diff the user has never seen.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 60a6acf906 Let a view be read while it is re-rendering
A re-render builds into an off-screen buffer and swaps it in when it has
read enough to paint. Deciding where the new content should be shown means
reading it before that swap: afterwards it is on screen already, and
whatever we then scroll to has been seen at the wrong position first.

So expose the off-screen buffer's diff-line contents and line count, the
latter for telling when a line found there has a screenful below it. The
contents come in two forms, the whole buffer and everything from a given
line on, so that a reader following the render as it loads can look at each
line once instead of re-reading the buffer per line.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 43968dbe53 Let a re-render put the view back where it was
A view that re-renders content the user is already looking at, laid out
differently — a different context size, another diff renderer — starts the
new render from the top, losing where they were. Where that is can't be
carried over as a scroll position, because a different layout of the same
content puts the line they were on somewhere else; it can only be found by
looking at what the re-render brings in.

So let a restore be installed on the buffer manager just before the
re-render is triggered. It rides the next command task, which asks it after
each line whether enough has arrived to show what it remembers, and then
hands it the first paint: the restore searches the off-screen buffer, swaps
it in, and places the view, in that order, so that the search happens while
the previous content is still displayed and the new content is never drawn
at the previous render's scroll position. A restore that placed the view
keeps the scroll reset new content would otherwise get; one that couldn't
find what it was looking for leaves the render to do what it would have
done anyway, and the lines-read count still has the last word on when to
paint, so a restore can never hold a render back for ever.

Some renderings can't be searched at all: a diff renderer is free to say
nothing about which line of which file each row shows, and then no line of
the old rendering can be looked for in the new one. There the offset into
the content is all that is left to go on, and it is nearer to where the user
was than the top is, so a re-render can also ask merely to be left where it
is. That request rides the next task the same way, and answers the same
question the scroll reset and the loading placeholder are asking: whether
what is coming is content the user has not seen.

Both outlive the task they were installed for, like the pending scroll reset
does and for the same reason: that task can be stopped and replaced by a
background refresh before it ever paints, leaving the replacement to honour
it. The loading placeholder stays out of the way while either is pending —
blanking the view for a message before putting the user back where they were
is the flicker they exist to avoid.

Nothing installs either of them yet.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan Haller 906770ff6d Fix AGENTS.md markdown syntax
VS Code changes *italics* to _italics_ when saving, so normalize these
once.
2026-10-01 17:07:52 +02:00
Stefan Haller be63685d63 AGENTS.md additions 2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 6ace682e50 Follow the selection with the search in the focused main view
Search the diff, then move the selection with the arrow keys, and
pressing "n" goes to the match after the one the search was last on
rather than to the one after the selection. So walking through the diff
and then picking up the search where you got to takes as many presses as
there are matches behind you.

List views and the staging view keep the current match in step with their
selection; do the same here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 8c51a920b2 Draw the diff selection as a narrow bar rather than across the line
Now that a diff view always carries a selection, the highlight fights the
diff itself for the line's colours. Painting the selection across the whole
line takes over the background, and a selected hunk becomes one solid block
with no boundary between what was removed and what replaced it — the more
lines you select, the less you can read.

This isn't specific to renderers like delta that say which side of the diff
a line is on by colouring its background, though they suffer most: git's own
output puts red and green text on that background, which reads badly too.
Since there is no rendering of a diff that a full-width highlight doesn't
degrade, there is nothing here worth configuring — the bar is simply what
diff views use.

Left edge only, and two columns wide: every convention for marking a row of
coloured content — change bars, diff gutters, selection gutters — puts the
marker on the left, and bracketing both edges reads as framing instead.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfde175e8a Edit the selected line of a diff from the focused main view
Reading a diff is often how you notice something to fix, and the file and
line are right there in front of you — so pressing edit opens the file at
the line under the selection, as it does in the staging view.

The line number the diff shows is the line number in the version the diff
is of, which for a commit's diff is not where that line sits today, so it
is carried forward the same way clicking a diff-renderer hyperlink already
does. A file header names no line, so it just opens the file.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 cfb88005c8 Jump by hunk and by file in the focused main view
Reading a diff of any size means moving in bigger steps than a line at a
time, which the staging view offers and the main view didn't. So the same
hunk keys work here, and n / N step from file to file — worth having only
here, since a diff spanning several files is something the staging view
never showed.

Where "the next file" begins isn't in the text: a diff renderer may print
whatever it likes above a file's content. So navigation lands on the first
row that states which file it belongs to, which is the file's header when
the source says so and its first content line otherwise. The anchor's own
file is found by scanning down rather than to the nearest row either way —
having just landed on a header, the nearest row above belongs to the file
we came from, and taking it would send the next press back where we
started.

A file you go to is brought to the top of the view, since the file is what
you went there for and the more of it is on screen the better. That only
applies where the view has to scroll at all: a file already on screen
leaves it where it is. In hunk mode what ends up selected is the file's
first change rather than the row the file begins at, and a large context
size can put that change further down than a screenful; the selection is
scrolled into view afterwards as any other jump's is, and the alignment
gives way where the two can't both hold.

The diff loads lazily, so a target below the loaded portion isn't there to
be found; rather than doing nothing, we read the rest in and look again.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:07:52 +02:00
Stefan HallerandClaude Opus 5 a5e72581f9 Select a range of diff lines by dragging
Selecting a range with the mouse is the obvious gesture once clicking
selects a line, and it's the only way to get a range without knowing the
keybindings.

The range has to be anchored where the mouse went down, which the view
can't tell us: a click in hunk mode selects a whole block, leaving the
view's own range anchor at the block's far end, so a drag from there would
grow the selection from the wrong end. So the clicked line is remembered
when the click happens, and the drag anchors there.

A drag that reaches the edge of the view keeps scrolling, using the same
autoscroller the staging view does — mouse capture means the pointer can be
dragged past the edge, and there is more diff down there than fits on
screen. Unlike the staging view, whose content is a string that is always
there in full, this diff loads lazily, so scrolling down has to keep
reading it in or the autoscroll would stop at the loaded edge.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 11a899852c Select a whole change block when focusing the main view in hunk mode
Users who set hunk mode as their default get it when entering the staging
view, and the focused main view is on its way to replacing that view, so it
has to behave the same: focusing selects a whole change block, and clicking
a change line selects that line's block, ready to act on. A click on a
context line still selects only that line, since the click points at it
precisely — you may well want to edit it. A click inside the block that is
already selected does the same, giving hunk mode up and going back to line
by line. That keeps a single line reachable with the mouse in a block too
long to see the end of, and matches how a click inside a range selection
already collapses it.

The block offered up is the first one that begins on screen, so that its
whole extent can be seen before acting on it. Only when none does — a change
too long to fit on the screen — is the block that reaches into the view from
above taken instead, and the selection then extends to its first line off
screen: focusing a diff must not move it, so nothing here scrolls.

The staging view makes one exception, and so must we, or a file that is one
solid block of changes (a file you just created, or deleted) would come up
with all of it selected. The staging view asks the parsed patch; we ask the
rendered diff the same question, which needs no second git invocation and
works over a whole commit's diff, where the answer differs per file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 83599e143b Make selecting a change block reachable without a controller
Focusing the main view is about to establish a hunk selection, and that
happens in a free function shared with the controller that focuses the
main view from a side panel, which has no main view controller to hand.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 ad95b633eb Let a main pane be named without the context package
The mode of a diff view's selection lives on the context of the pane showing
it, and until now only the controllers that drive the selection needed to
name that pane, all of which have it as the concrete context it is.

A side panel is about to be handed the pane a command was invoked in, so
that it can act on what is selected there — and it can't be handed a
context, the interface it is handed one through being declared where a
context's concrete type isn't available. So the state moves to where such an
interface can speak of it, and the panes gain one: a context that has a diff
selection.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:58 +02:00
Stefan HallerandClaude Opus 5 6a6683729f Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 81c27b2ff8 Separate reading a fixed number of lines from reading to the end
ReadToEnd holds a gocui task while it reads, so that lazygit doesn't count as
idle while something waits on the result. That has nothing to do with reading
all the way to the end, and the next commit needs it for a bounded read too, so
the two are pulled apart.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 a0b9ca978c Guard View.LinesHeight against a concurrent write
The count comes from the view's buffer, which a rendering task appends to on
its own goroutine, so reading it without the write mutex is a data race. Nobody
called it until now, which is why nothing has tripped over it; the next commit
does, from the UI thread while a render is still loading.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ad3a337675 Take the focused main view's context by its concrete type
Both callers pass a main context, and focusing one is about to need more
of it than the Context interface offers. Saying so in the signature also
retires the type assertion that was there only to reach ClearSearchString.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 50d9900b72 Remove the plumbing for clicking the focused main view
With both implementations gone, nothing is left that lets a side panel
handle a click in the focused main view, so the mechanism for attaching
one to a context can go as well.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ba3643149a Stop diving into a patch explorer when clicking the focused main view
Clicking a line of the focused main view entered the staging or patch
building panel at that line. The focused main view is about to gain a
selection of its own, which is what a click there should set — and with
the explorers on their way out, the dive has nowhere to go.

Nothing replaces the gesture yet, so for the next couple of commits a
click in the focused main view does nothing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 75d153edf5 Classify which side panels show a diff in their main view
The focused main view is about to show a selection, but only where there
are diff lines to act on: a branch's commit log or the status dashboard
has nothing to select. Rather than have the main view guess from the
rendered content, let the side panels say so, since each of them knows
what it renders.

The classification is finer than a yes/no because acting on a selection
means different things per panel — staging into the working tree for the
files panel, taking lines into a custom patch for the commit panels — and
those actions want the same one answer as this. Only whether a panel
shows a diff at all is read for now.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 46bd2ddb23 Fold ViewSelectionController into MainViewController
The focused main view is about to get a real diff selection, which makes
its up/down/page/top/bottom keys mode-aware: what they do depends on the
select state the main view controller owns. Keeping them in a separate
controller would mean either duplicating that state or reaching across
controllers for it, so move them to where the state will live. The two
controllers were attached to the same pair of contexts and nothing else,
so nothing else can notice.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 ea6d77c3e6 Ask diff renderers for OSC 1717 metadata
A renderer that speaks the protocol emits nothing unless it is asked to,
so that its output stays plain wherever it is used outside lazygit. The
variable names the versions we understand.

git is one of the renderers we ask. It has no pager to spawn, and so no
terminal to spawn one in, but it still renders the diff itself — for the
word-diff formats, whose markup nothing else could resolve — so the
request has to be made before we decide a pty isn't needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 eeaf94c79e Take a diff renderer at its word about a diff line
Where a renderer states which line of which file it is rendering, take
that as the line's identity. The renderer knows, and for a rendering
that no longer looks like a diff nothing else does. Reading the rendered
text stays the way for the renderings that keep a diff's structure, and
for the renderers that say nothing at all.

Which of the two is used is settled for the rendering as a whole, not
row by row. A rendering with records is not parsed at all, not even for
the rows the renderer says nothing about. Its text is no unified diff,
and a row of it can read like one. Under delta, a commit that adds a
test whose input is a diff shows the test's "diff --git a/img.png
b/img.png" line as a row of its own; parsed, that row opens a file
section that runs to the end of the rendering, and every row delta puts
between hunks and files comes out as a header of a file called img.png.
So the untagged rows of such a rendering stay unresolved, as the
protocol has it (spec section 6.4).

Header records are accepted too. This lets a renderer point at a file
with no content lines, such as a pure rename, a mode change or a binary
file: for those files the header is the only row in the diff.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 828c73bad4 Rename parsedDiffLine.RelPath to Path
A diff line's identity is about to become recoverable from a second
source, a diff renderer's own records, and a renderer states the path
however it likes — absolute paths included. The field can't promise
repo-relative any more.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 84ddc5bacf Keep the OSC 1717 records that cover no cell
Wherever two diff lines end up on one rendered line, a renderer emits
their records back to back: the deletion and the addition of a
modification collapsed into a single column, or a banner announcing a
file and its first hunk at once. A changed line that is empty is
rendered as its record alone. Attaching a record only to the cells it
precedes loses all of these — the last record of a run wins, and an
empty changed line becomes a line we can say nothing about at all.

Give such a record a cell of its own instead. It renders nothing, so the
diff looks the same, but the line keeps every record it was given.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 d3db58c2ec Swallow a diff renderer's protocol handshake
Before the diff, a conforming renderer emits one OSC 1717 record that
carries only the version — its way of announcing that it speaks the
protocol at all, without a host having to inspect what it renders. It
describes no line, so keeping it would give the first line of the diff a
record that says nothing about it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 d3acac38c4 Read the OSC 1717 records a diff renderer emits
A diff renderer that restructures the diff — into columns, or with the
+/- markers replaced by colour — leaves us no way to tell which line of
which file a rendered row came from, which is what acting on the row
requires. The OSC 1717 protocol has the renderer say so directly: it
prefixes each line it renders with a record naming the file and the
line's position in the old and new versions of it.

Attach each record to the cells it precedes, so that a row's records
survive wrapping and the columns of a side-by-side rendering, and hand
them to readers together with the row's text: the two have to describe
the same buffer, and a re-render can rebuild it between two reads.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 e0deb30faf Recognize OSC numbers with more than one digit
The OSC parser dispatched on a single character, so only the
single-digit OSC 8 could ever be recognized; the diff-line metadata
protocol we are about to read uses OSC 1717.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 36755f07f1 Decode the path of a diff header the way git writes it
git doesn't just print the path: it terminates it with a tab when the
path contains a space, and C-quotes the whole field when the path
contains anything it won't print raw — with core.quotePath, which is on
by default, that means any non-ASCII byte, so a file called café shows
up as "b/caf\303\251".

Taking the field verbatim therefore gives a path that doesn't exist, for
a whole class of perfectly ordinary file names. The quoting is Go's own
string syntax, octal escapes and all, so decoding it is a call to
strconv.Unquote.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 1cf479d651 Recover the identity of a diff line from the rendered diff
To act on the line the user is pointing at in a diff view — to stage it,
to open it in an editor, to keep the cursor on it while the diff is
regenerated — we need to know which file it belongs to and where it sits
in that file. Only the diff the view was rendered from knows that, and
by the time it's on screen all we have is text.

So parse it back: the view's contents are (usually) a unified diff, and
running them through the patch parser recovers each row's file, kind and
line numbers. "Usually" is why this goes behind a seam, and why it
answers "I don't know" rather than guessing: a diff renderer is free to
restructure what it prints, and a wrong answer here means acting on the
wrong line of the wrong file.

Parsing a whole buffer is a separate entry point from parsing a single
line, because a caller resolving every row of a large diff must not
re-parse a file's section once per line of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 e71d497157 Keep a view's lines as they were written, beside their cells
A reader that parses a view's content rather than showing it wants the
text the writer wrote, and the cells don't always spell it. A tab is
expanded into the spaces it fills, so a line read back from the cells
ends in one to four spaces where the writer put a tab. A carriage
return moves the write cursor back to the start of the line, so the
text written after it overwrites what came before.

The parser of the main view's diff, which the next commit adds, meets
the first case in every header of a file whose path contains a space.
git terminates the path field of a "---" or "+++" line with a tab
then, and a parser reading the cells takes the spaces the tab became
for part of the path. That path names a file that doesn't exist, so
the file's lines can't be acted on.

Keep the text as written per line, from the first character on that
the cells spell differently, so that a line without a tab or a
carriage return costs nothing. LinesAsWritten hands it out the way
BufferLines hands out the cells' text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 89e578123e Clear a pending newline when overwriting lines in place
OverwriteLines is asked for a line and writes the one below it when the
write before it ended in a newline. The view holds such a newline back
until more content arrives, so that it doesn't end in an empty line,
and OverwriteLines moved the write cursor without letting go of it, so
the write that followed advanced to the next line first.

Move the cursor through SetWritePos, which drops the pending newline
along with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Fable 5.1 7c355a3801 Demonstrate that overwriting lines after a pending newline lands a line low
A view holds back the newline that ends a write until more content
arrives, so that it doesn't end in an empty line. OverwriteLines moves
the write cursor to the line it is given without letting go of that
pending newline, so the write that follows advances first and lands on
the line below. Nothing in lazygit overwrites lines right after such a
write today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 7455a42432 Let callers map between view lines and buffer lines
Everything about a diff's content — which file and line a row belongs
to, whether it's a change — is a property of the unwrapped buffer line,
while the cursor, clicks and the range selection all speak in view
lines, which count wrapped segments. Reading the content under the
cursor therefore needs the mapping in both directions, and doing it
outside gocui isn't possible: the wrapping is internal, and the caller
couldn't take the view's lock across the lookup and the read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 b61bd44a24 Add a well-formedness check for a parsed patch
Parse is lenient: it takes any text and reads a diff out of it, which is
what we want when we hand it a diff, but it has no way to say "this
isn't one". We're about to parse the *rendered* contents of a diff view,
which a diff renderer is free to restructure — putting the line numbers
in a gutter, say, shifts the +/- marker off the start of each body line,
so every line reads as context and the parse silently lies about which
lines are changes.

Comparing each hunk's body against the lengths its header declares
catches exactly that, without teaching us anything about any particular
renderer's layout: a faithful unified diff agrees with its headers, a
restructured one doesn't.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 6049af5683 Add an old-file counterpart to Patch.LineNumberOfLine
Identifying a change line of a diff by its file line number needs both
sides: two consecutive deletions sit at the same new-file position, so
only their old-file line numbers tell them apart. LineNumberOfLine only
answers for the new file, which leaves deletions ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 eeb59aff16 Keep a resized view's place in the content it wraps
Wrapping the content at another width moves every line of it to a
different view line. The positions into the view are all view lines: the
scroll offset, the cursor, a range's anchor. Each of them is then left
pointing at a line it was never on. Committing the last of the staged
changes widens the main view by half a screen, and that moves a selected
hunk somewhere else entirely.

Carry the positions through the lines of content they were on. A position
always meant a line of content rather than a view line. The line the
cursor is on keeps the row it was drawn on, so it stays in front of the
user rather than the view scrolling under it; a view with no cursor on
screen keeps its own place instead. A range's ends go on the outermost
segments of their lines, since a range covers lines of content and not
the segments those lines are drawn as.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0758533125 Demonstrate that resizing a wrapping view moves its selection
The scroll offset, the cursor and a range's anchor are all view lines, which
count the segments each line of the content is wrapped into. A change of
width wraps the content differently, so every one of them ends up on a
different line than the one it was put on.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0bce007abe Report a wrapped selection by the lines of content it covers
A test asks which lines of a view are selected; a wrapping view's cursor and
range anchor answer in view lines, which count the segments each line is
drawn as. Going through the segment-to-line mapping keeps the answer in the
terms the question was asked in, and a line the selection covers several
segments of is reported once.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5 0ae23b15a1 Demonstrate that a wrapped selection is reported by segment
The lines a selection covers are asked for by view line, which counts the
segments a wrapping view breaks a line into, and then used to index the
content, whose lines are unwrapped. The two agree only for a view whose
content doesn't wrap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 16:56:57 +02:00
Stefan HallerandClaude Opus 5.5 0afb94e97b Lay git's own diff out to the width the layout gives the view
Changing the screen mode renders the main view again, and so does
anything else that changes its size along with what it shows. With git's
own diff, that render laid a commit's diffstat out to the width the view
had before. The graph then wrapped onto rows of its own in a view that
got narrower, and stopped short in one that got wider.

A diff renderer's render is created after the layout pass, since only
the layout settles the view's size, and takes its width there. git's own
diff took its width straight away instead, when the render was asked
for. Create its task after the layout as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:50:48 +02:00
Stefan HallerandClaude Opus 5.5 6b38e8fcde Demonstrate that git's diffstat keeps the width from before a screen mode change
Pressing + in the commits panel moves to half-screen mode and renders
the commit's diff again in the narrower main view. With git's own diff,
the diffstat graph comes out as wide as the view was before, and wraps
onto rows of its own.

ContainsViewLines asserts on the rows the view draws, after wrapping.
The existing line assertions see the lines of the content and can't tell
a wrapped line from one that fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:42:10 +02:00
182 changed files with 11764 additions and 495 deletions
+34 -6
View File
@@ -63,7 +63,9 @@ Prefer a fine-grained commit history. Commits should be as small as possible
while still being meaningful and self-contained.
- **Every commit must compile and pass all tests.** No "WIP" commits, no
commits that leave the tree broken and rely on a follow-up to fix it.
commits that leave the tree broken and rely on a follow-up to fix it. A
`fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
what one may leave broken until it is folded in.
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Every commit must be lint-clean.** Run `just lint` before committing —
@@ -134,6 +136,32 @@ target, make the change, `git commit --fixup=<target>`, then
branch. The fixup stays a separate, reviewable commit; only its position
changes.
**A fixup may leave commits before it broken until it is folded in.** If a
`fixup!` on an early commit deletes something that a later commit still uses,
the later commit doesn't build until its own `fixup!`, right behind it, catches
up; the same goes for lint. That is expected. The rules above about every
commit compiling, testing and linting clean describe the history _after_
autosquash, and I fold fixups in soon after reviewing them. Never amend a
commit directly, or edit the commits between two fixups, to keep every commit
of the un-squashed history green. The reviewable fixup is worth more than a
green intermediate state. Verify at each fixup instead, since the tree there
is what the folded-in history will have at that point, and say in the handoff
which commits stay broken until which fixup.
**After a mid-stack fixup, check every branch tip above it, not just the stack
tip.** A fixup that deletes or renames something rewrites every commit replayed
above it, and a commit further up can hide the damage at the tip. A helper
whose last caller the fixup deleted is flagged as unused by `just lint` at the
tip of its own PR, but a later PR that calls it again makes the stack tip lint
clean. Each PR is reviewed and merged on its own, so each PR branch tip has to
be green on its own. After the replay, run `just build`, `just unit-test` and
`just lint` at each branch tip from the insertion point up. If the fixup deleted
or renamed a symbol, also build every replayed commit, for example with
`git -c rebase.autosquash=false rebase -x 'go build ./...' <insertion point>`;
unchanged commits are fast-forwarded, so their hashes stay, and the commits a
fixup is expected to leave broken stop it, so `git rebase --continue` past
those.
If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to
@@ -180,7 +208,7 @@ looks messy. The whole point of a fixup is that the iteration stays
**visible and reviewable**; squashing it away yourself destroys exactly the
artifact it exists to create. Collapsing fixups into their targets is the
user's action, taken once they've reviewed the iterations. Every mention of
`--autosquash` in this section describes what the *user* will eventually
`--autosquash` in this section describes what the _user_ will eventually
run, never a step for you to perform. If you think the history is ready to
collapse, say so and leave it to them.
@@ -320,7 +348,7 @@ refactor to an earlier commit (but don't do it without asking first).
## Don't read model state right after a `Refresh`
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
_enqueues_ the model update onto the UI thread. So when `Refresh` returns, the
model is **not** updated yet — the write is still queued. Reading a field
synchronously right after refreshing its scope reads the stale, pre-refresh
value (and this is true even for SYNC refreshes):
@@ -406,7 +434,7 @@ column. Applies only to `pkg/i18n/english.go`.
## Code comments are for future readers, not development history
Comments in source code explain *why this code is shaped the way it is*. They
Comments in source code explain _why this code is shaped the way it is_. They
are not the place to narrate the path we took during development — what was
tried first, what didn't work, what's "more reliable" or "cleaner" than some
alternative. That framing is interesting in the moment, but it's noise to
@@ -422,7 +450,7 @@ Avoid phrasings like:
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The
commit message, which is the durable record of _why this change was made_. The
code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists.
@@ -475,7 +503,7 @@ So:
struct, run `just generate` and include the regenerated
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
*only* to `userConfig`, because those comments are fed through the doc
_only_ to `userConfig`, because those comments are fed through the doc
generator; comments on every other struct follow the normal Go wrapping
conventions. For `userConfig` fields, write each sentence (or paragraph)
as a single unwrapped line, however long — the generator re-wraps them for
+2
View File
@@ -845,6 +845,8 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
+30
View File
@@ -223,7 +223,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
@@ -328,7 +343,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Search the current view by text | |
## Stash
+30
View File
@@ -192,7 +192,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -305,7 +320,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+30
View File
@@ -84,7 +84,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 검색 시작 | |
## Stash
@@ -162,7 +177,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
+30
View File
@@ -231,7 +231,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Start met zoeken | |
## Patch bouwen
@@ -306,7 +321,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <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. |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
| `` w `` | Commit veranderingen zonder pre-commit hook | |
| `` C `` | Commit veranderingen met de git editor | |
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Start met zoeken | |
## Staging
+30
View File
@@ -99,7 +99,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <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. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Drzewa pracy
@@ -201,7 +216,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <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. |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
| `` C `` | Zatwierdź zmiany używając edytora git | |
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Panel główny (scalanie)
+30
View File
@@ -235,7 +235,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <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. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
@@ -337,7 +352,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <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. |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <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 | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Submeter mudanças em staging |
| `` w `` | Fazer commit de alterações sem pré-commit | |
| `` C `` | Enviar alteração usando um editor Git | |
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Pesquisar na visualização atual por texto | |
## Stash
+30
View File
@@ -74,7 +74,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Найти | |
## Главная панель (Индексирование)
@@ -106,7 +121,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -286,7 +286,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -334,7 +349,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 状态
@@ -75,7 +75,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -356,7 +371,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 狀態
+4 -2
View File
@@ -135,8 +135,10 @@ func NewGitCommandAux(
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
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(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
// A patch is built from git's own diff: what a diff renderer would make of it
// is a picture of it, not something that can be applied.
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
})
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
+3 -3
View File
@@ -240,12 +240,12 @@ 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, mode DiffMode) *oscommands.CmdObj {
cmdArgs := NewGitCmd("show").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)).
Arg("--stat").
Arg("--decorate").
Arg("-p").
+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, DiffModeRendered).Run())
runner.CheckForMissingCalls()
})
}
+128 -3
View File
@@ -2,10 +2,135 @@ package git_commands
import (
"fmt"
"os"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/mgutz/str"
)
// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol
// emits before anything else, to announce that it does: a version-only record, with
// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and
// gocui's escape interpreter for how it is kept off the screen on a real render.
const metadataHandshake = "\x1b]1717"
// probeWidth is the width a probe asks the renderer to lay out to. A probe has
// nothing to render, so any width does, as long as it is one a renderer will
// accept: a renderer that lays its rendering out in columns may well refuse a
// width of zero, and would then never get as far as announcing itself.
const probeWidth = 80
// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states
// which line of which file it is rendering, by running it on empty input and looking
// for the handshake. The answer decides whether a diff the renderer produced can be
// acted on at all, or has to be replaced by git's own when the user wants to act on it
// (see DiffLineHelper.MainViewDiffMode).
//
// Asking rather than watching a real render: the handshake is the renderer's first
// output whatever the diff, so the answer is a property of the renderer, known before
// we render anything — where watching would have to see a diff go by first, and would
// be fooled by a diff with no lines to describe.
//
// No terminal is needed. git only invokes a stdin filter when it thinks it is talking
// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is
// set, so it can be run directly with empty input.
func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool {
manager := self.diffRendererConfigManager
values := config.DiffRendererValues{Width: probeWidth, DiffContext: 3}
switch manager.GetDiffRendererType() {
case config.DiffRendererType_StdinFilter:
if command, err := manager.GetStdinFilterCommand(values); err == nil && command != "" {
return self.probeEmitsMetadata(self.cmd.NewShell(command, ""))
}
case config.DiffRendererType_ExtDiff:
// An empty command means git's own diff.external config, which picks a driver
// per file through .gitattributes: there is no one renderer to ask, and a single
// diff can be produced by several, so we take it that it says nothing.
if command, err := manager.GetExternalDiffCommand(values); err == nil && command != "" {
return self.externalDiffEmitsMetadata(command)
}
case config.DiffRendererType_RawGit:
// git describes only the formats whose output can't be read back as a diff, and
// asked with the renderer's own arguments it answers for exactly the format
// those select: a handshake for a word diff, silence for a unified one. With no
// arguments there is nothing to fall back to anyway, since this already is git's
// own diff.
if args := manager.GetRawGitArgs(); len(args) > 0 {
return self.rawGitEmitsMetadata(args)
}
}
return false
}
// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments.
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
oldPath, newPath, cleanup, ok := self.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 asks an external diff command, invoking it the way git
// invokes one — with the seven positional arguments of git's diff.external convention —
// over two empty files, so that it announces itself without having a diff to render.
func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool {
oldPath, newPath, cleanup, ok := self.probeFiles()
if !ok {
return false
}
defer cleanup()
args := append(str.ToArgv(externalDiffCommand),
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
return self.probeEmitsMetadata(self.cmd.New(args))
}
// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup
// that removes them. Empty, because what the probe wants is for the renderer to announce
// itself, not for it to have anything to say.
func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
tempDir := self.os.GetTempDir()
oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*")
if err != nil {
return "", "", nil, false
}
oldFile.Close()
newFile, err := os.CreateTemp(tempDir, "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")
// A renderer may well object to being handed nothing to render. We want to know
// whatever it said before objecting, and that is captured either way.
output, _ := cmdObj.RunWithOutput()
return strings.Contains(output, metadataHandshake)
}
type DiffCommands struct {
*GitCommon
}
@@ -18,13 +143,13 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
// 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 {
func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(diffArgs...).
Dir(self.repoPaths.worktreePath).
ToArgv(),
+35
View File
@@ -0,0 +1,35 @@
package git_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
)
// DiffMode says what a diff command's output is for. This decides whether the
// configured diff renderer produces it, and whether it is coloured.
type DiffMode int
const (
// DiffModeRendered is the diff as the user has arranged for it to look: through the
// diff renderer, with the renderer's own arguments and its preference about colour.
DiffModeRendered DiffMode = iota
// DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form
// couldn't be acted on.
DiffModeRaw
// DiffModePlain is git's own uncoloured diff, for building patches from and copying
// text out of rather than for looking at.
DiffModePlain
)
// colorArg returns the value to pass to git's --color for this mode. Rendered output is
// coloured however the renderer wants its input; a raw diff gets git's own colour, which
// is the point of it; a plain one is for reading as text, not for looking at.
func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string {
switch self {
case DiffModeRendered:
return diffRendererConfigManager.GetColorArg()
case DiffModeRaw:
return "always"
default:
return "never"
}
}
@@ -123,16 +123,21 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
contextSize := userConfig.Git.DiffContextSize
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
return self.
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
Arg(fmt.Sprintf("--unified=%d", contextSize)).
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
// Ignoring whitespace is about what the user wants to see, so it holds for a raw
// diff as much as for a rendered one. Patches are built from a plain diff,
// where a diff that leaves changes out would apply to nothing.
ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
// The renderer's own arguments to git — a word diff, say — are part of the
// rendering, so they go with it.
ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...)
}
func (self *GitCommandBuilder) ToArgv() []string {
+3 -3
View File
@@ -80,14 +80,14 @@ 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, mode DiffMode) *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(), mode).
Arg("-p").
Arg("--stat").
Arg("-u").
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
Dir(self.repoPaths.worktreePath).
ToArgv()
+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, DiffModeRendered).Args()
assert.Equal(t, s.expected, cmdStr)
})
}
+10 -20
View File
@@ -383,9 +383,9 @@ 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 {
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, mode, cached, file.Names()).RunWithOutput()
return s
}
@@ -393,18 +393,13 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool,
// in the working tree. node is the item they belong to; all it decides is
// whether git has to compare against /dev/null, which is the case for a file
// that isn't in the index yet.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj {
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
ArgIf(cached, "--cached").
ArgIf(noIndex, "--no-index").
Arg("--").
@@ -420,25 +415,20 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
// both paths must be passed to git for the rename to be detected.
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) {
fileNames := []string{fileName}
if previousPath != "" {
fileNames = append(fileNames, previousPath)
}
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput()
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj {
cmdArgs := NewGitCmd("diff").
Config("diff.noprefix=false").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
Arg("--submodule").
Arg(fmt.Sprintf("--color=%s", colorArg)).
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg(from).
Arg(to).
ArgIf(reverse, "-R").
+15 -15
View File
@@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) {
type scenario struct {
testName string
file *models.File
plain bool
mode DiffMode
cached bool
ignoreWhitespace bool
contextSize uint64
@@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: true,
ignoreWhitespace: false,
contextSize: 3,
@@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: true,
mode: DiffModePlain,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: false,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: true,
contextSize: 3,
@@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 17,
@@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) {
HasStagedChanges: false,
Tracked: true,
},
plain: false,
mode: DiffModeRendered,
cached: false,
ignoreWhitespace: false,
contextSize: 3,
@@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) {
}
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result := instance.WorktreeFileDiff(s.file, s.plain, s.cached)
result := instance.WorktreeFileDiff(s.file, s.mode, s.cached)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
})
@@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse bool
fileName string
previousPath string
plain bool
mode DiffMode
ignoreWhitespace bool
contextSize uint64
runner *oscommands.FakeCmdObjRunner
@@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 123,
runner: oscommands.NewFakeRunner(t).
@@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
to: "0987654321",
reverse: false,
fileName: "test.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: true,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
reverse: false,
fileName: "new.txt",
previousPath: "old.txt",
plain: false,
mode: DiffModeRendered,
ignoreWhitespace: false,
contextSize: 3,
runner: oscommands.NewFakeRunner(t).
@@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode)
assert.NoError(t, err)
assert.Equal(t, expectedResult, result)
s.runner.CheckForMissingCalls()
+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 header line ('2' and '3' in the above example),
// kept so that we can check the parsed body against them (see
// Patch.IsWellFormed). Only set 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: the old start, the old length (omitted by git when it is
// 1), the new start, the new length (likewise), 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 length capture of a hunk header, 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 {
+69
View File
@@ -79,6 +79,44 @@ 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 — a diff renderer that puts line numbers in
// a gutter, say, 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. That
// makes this the test for whether a rendered diff can be parsed as a unified diff
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
// by Parse, which is where the declared lengths come from.
func (self *Patch) IsWellFormed() bool {
return self.isWellFormed(false)
}
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
// so every hunk but the last has to match its header exactly, as before, while the last
// one only has to fit within what its header declares.
//
// The check exists to tell a faithful rendering from a restructured one, and it still
// does that. A rendering that moves the +/- marker off the start of the line makes us
// read a change as context, and a context line counts towards both lengths, so such a
// hunk comes out longer than its header declares rather than shorter.
func (self *Patch) IsWellFormedSoFar() bool {
return self.isWellFormed(true)
}
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
for i, hunk := range self.hunks {
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
return hunk.oldLength() <= hunk.declaredOldLength &&
hunk.newLength() <= hunk.declaredNewLength
}
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 +152,37 @@ 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
}
// 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 {
+2 -2
View File
@@ -33,7 +33,7 @@ type fileInfo struct {
}
type (
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error)
)
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
@@ -162,7 +162,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
return info, nil
}
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath)
if err != nil {
return nil, err
}
+156
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
// tell them apart.
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,148 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestIsWellFormed(t *testing.T) {
// The body of a diff as rendered with the +/- markers moved out of the text
// and into a gutter: every body line now reads as context, so the lengths no
// longer match the header.
const gutterMangled = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"renameWithModificationDiff", renameWithModificationDiff, true},
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
{"twoHunks", twoHunks, true},
{"consecutiveDeletions", consecutiveDeletions, true},
{"newFile", newFile, true},
{"deletedFile", deletedFile, true},
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
{"exampleHunk", exampleHunk, true},
{"gutterMangled", gutterMangled, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
})
}
}
func TestIsWellFormedSoFar(t *testing.T) {
// A diff read only as far as the middle of its second hunk.
const cutShort = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
+orange
...
...
...
@@ -8,6 +8,8 @@ grape
...
...
`
// The same diff cut short in its first hunk, so that the second is missing
// entirely rather than short.
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
`
// A rendering with the +/- markers moved into a gutter, cut short: reading the
// changes as context makes the hunk longer than its header declares, not shorter,
// so it doesn't pass for a diff we only have the beginning of.
const gutterMangledAndCutShort = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
melon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"twoHunks", twoHunks, true},
{"cutShort", cutShort, true},
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
})
}
}
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},
},
{
testName: "renameWithModificationDiff",
patchStr: renameWithModificationDiff,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
},
}
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 TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
+4
View File
@@ -672,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
type KeybindingMainConfig struct {
PrevHunk Keybinding `yaml:"prevHunk"`
NextHunk Keybinding `yaml:"nextHunk"`
PrevFile Keybinding `yaml:"prevFile"`
NextFile Keybinding `yaml:"nextFile"`
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
PickBothHunks Keybinding `yaml:"pickBothHunks"`
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
@@ -1182,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
Main: KeybindingMainConfig{
PrevHunk: Keybinding{"<left>", "h"},
NextHunk: Keybinding{"<right>", "l"},
PrevFile: Keybinding{"N"},
NextFile: Keybinding{"n"},
ToggleSelectHunk: Keybinding{"a"},
PickBothHunks: Keybinding{"b"},
EditSelectHunk: Keybinding{"E"},
+49 -5
View File
@@ -50,6 +50,22 @@ func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
return GocuiEvent{Type: eventKey, Key: key}, &fired
}
// runQueuedWork runs what the gui has queued for the following passes of the
// event loop, which is where EndBlockingEvents leaves the replay of the keys it
// buffered.
func runQueuedWork(t *testing.T, g *Gui) {
t.Helper()
for {
ev, ok := g.userEvents.dequeue()
if !ok {
return
}
assert.NoError(t, ev.f(g))
ev.task.Done()
}
}
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
@@ -64,12 +80,37 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
// Unblocking replays the buffered keys.
assert.NoError(t, g.EndBlockingEvents())
// Unblocking queues the replay rather than dispatching from here.
g.EndBlockingEvents()
assert.Len(t, *fired, 1, "the replay must wait for the event loop")
runQueuedWork(t, g)
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
assert.Empty(t, g.bufferedKeyEvents)
}
func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
other := GocuiEvent{Type: eventKey, Key: NewKeyRune('y')}
g.SetKeybinding("main", other.Key, func(*Gui, *View) error {
*fired = append(*fired, 0)
return nil
})
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
g.EndBlockingEvents()
// A key pressed while the replay is still queued joins the end of the buffer:
// dispatching it now would put it ahead of the keys buffered before it.
assert.NoError(t, g.handleEvent(&other))
assert.Empty(t, *fired)
runQueuedWork(t, g)
assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed")
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
@@ -79,11 +120,13 @@ func TestBlockingEvents_NestsWithCounter(t *testing.T) {
assert.NoError(t, g.handleEvent(&keyEvent))
// The inner block ending still leaves us blocked: no replay yet.
assert.NoError(t, g.EndBlockingEvents())
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.Empty(t, *fired)
// Only the outermost block ending replays.
assert.NoError(t, g.EndBlockingEvents())
g.EndBlockingEvents()
runQueuedWork(t, g)
assert.Len(t, *fired, 1)
}
@@ -94,5 +137,6 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
assert.NoError(t, g.handleEvent(&click))
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
assert.NoError(t, g.EndBlockingEvents())
g.EndBlockingEvents()
runQueuedWork(t, g)
}
+97 -20
View File
@@ -20,6 +20,25 @@ type escapeInterpreter struct {
instruction instruction
hyperlink strings.Builder
// the digits of the OSC number seen so far, while we don't yet know which
// OSC this is
oscNumber strings.Builder
// the payload of an OSC 1717 sequence, in which a diff renderer states
// which line of which file it is about to render; accumulated like
// hyperlink, and attached to the cells that follow it
metadata strings.Builder
// whether the payload currently in metadata has reached a cell, so that one
// that never does can be recognized and kept as an orphan
metadataConsumed bool
// OSC 1717 payloads that no cell took, because the next record followed
// with nothing rendered in between. A renderer emits records back to back
// wherever two diff lines share a rendered line — the deletion and the
// addition of a modification collapsed into one column, or a banner
// announcing a file and its first hunk at once. The write loop gives these
// cells of their own, so that a line keeps every record it was given rather
// than only the last.
orphanedMetadata []string
// 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 +101,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -427,27 +446,42 @@ 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 the ';' that terminates it, then
// dispatch on the whole number rather than on a single digit.
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 an OSC we understand — it has no number, or a character
// follows the number where the ';' should be. Rather than
// erroring, which would reset state mid-OSC and leak the rest of
// the sequence into the view as literal text, skip to its
// terminator, which this character may already be.
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 +497,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 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
return false, nil
}
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
// record, keeping the payload it held as an orphan if no cell took it (see
// orphanedMetadata).
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 orphaned payloads 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 nothing beyond the version. A diff renderer emits such a record ahead
// of everything else to announce that it speaks the protocol, so that a host can
// find that out by asking rather than by inspecting a rendering. It says nothing
// about any line, so it must not attach to the line that follows it; a per-line
// record always has fields, and is 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; {
+1
View File
@@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
"\x1b]8bogus\x07", // OSC 8 missing ';'
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
}
+38 -7
View File
@@ -236,10 +236,13 @@ type Gui struct {
// blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are
// dropped outright. It's a counter so blocking can nest. Both fields are
// only touched on the UI thread. See BeginBlockingEvents.
// dropped outright. It's a counter so blocking can nest. replayPending says
// that the replay is queued but hasn't run yet, and input is withheld until
// it has. All three fields are only touched on the UI thread. See
// BeginBlockingEvents.
blockInputCount int
bufferedKeyEvents []GocuiEvent
replayPending bool
}
type NewGuiOpts struct {
@@ -438,7 +441,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.ClearViewLines()
v.RewrapContent()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -930,12 +933,33 @@ func (g *Gui) BeginBlockingEvents() {
// normal dispatch path, so they act on the now-current context (a key whose
// binding no longer exists is simply ignored, just as if it had been pressed
// now). Must be called on the UI thread.
func (g *Gui) EndBlockingEvents() error {
//
// The replay is queued rather than run here, so that the buffered keys arrive on
// a later pass of the event loop, as they would have if the user had pressed them
// then. Running them here dispatches them from the middle of whatever the caller
// was doing. If a caller ends the block partway through updating the screen, a
// handler then acts on state the caller has yet to finish writing.
func (g *Gui) EndBlockingEvents() {
g.blockInputCount--
if g.blockInputCount > 0 {
return nil
return
}
// Input stays withheld until the replay has run. Gui events are dispatched in
// preference to queued work (see processRemainingEvents), so a key pressed
// before the replay gets its turn would otherwise be handled ahead of the keys
// buffered before it.
g.replayPending = true
g.Update(func(*Gui) error { return g.replayBufferedKeys() })
}
// replayBufferedKeys dispatches the keys withheld while input was blocked, and
// lets input through again. One of their handlers may block input afresh, and
// then the keys after it are withheld in their turn, to be replayed when that
// block ends.
func (g *Gui) replayBufferedKeys() error {
g.replayPending = false
buffered := g.bufferedKeyEvents
g.bufferedKeyEvents = nil
for i := range buffered {
@@ -1206,7 +1230,7 @@ func (g *Gui) processRemainingEvents() (bool, error) {
// handleEvent handles an event, based on its type (key-press, error,
// etc.)
func (g *Gui) handleEvent(ev *GocuiEvent) error {
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
if g.withholdingInput() && eventWithheldWhileBlocking(ev) {
if ev.Type == eventKey {
// Buffer keys so they replay against fresh state on unblock.
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
@@ -1235,6 +1259,13 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
}
}
// withholdingInput reports whether events are being kept from the handlers. They
// are while a block is in force, and on until the keys it buffered have been
// replayed.
func (g *Gui) withholdingInput() bool {
return g.blockInputCount > 0 || g.replayPending
}
// eventWithheldWhileBlocking reports whether an event must not reach the
// handlers while input is blocked (see BeginBlockingEvents). Key events are
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
@@ -1576,7 +1607,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.ClearViewLines()
v.RewrapContent()
}
}
g.maxX, g.maxY = maxX, maxY
+444 -25
View File
@@ -5,6 +5,7 @@
package gocui
import (
"bytes"
"fmt"
"io"
"slices"
@@ -163,6 +164,12 @@ type View struct {
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -251,23 +258,138 @@ type pos struct {
x, y int
}
// call this in the event of a view resize, or if you want to render new content
// without the chance of old content still appearing, or if you want to remove
// a line from the existing content
// call this if you want to render new content without the chance of old content
// still appearing, or if you want to remove a line from the existing content. For
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
func (v *View) clearViewLines() {
v.tainted = true
v.viewLines = nil
v.clearHover()
}
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
// the segments each line is wrapped into, so wrapping the content at another
// width leaves every one of them pointing at a different line.
//
// Call it on the UI thread whenever the view's size changes; a task goroutine may
// be writing the content concurrently, and all of this is state writeMutex
// protects.
func (v *View) RewrapContent() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
origin := v.contentPosOf(v.oy)
cursor := v.contentPosOf(v.oy + v.cy)
anchor := v.contentPosOf(v.rangeSelectStartY)
cursorRow := v.cy
v.clearViewLines()
v.refreshViewLinesIfNeeded()
if !origin.ok {
return
}
cursorLine, cursorOk := v.viewLineOf(cursor)
if anchorLine, ok := v.viewLineOf(anchor); ok {
v.rangeSelectStartY = anchorLine
if cursorOk {
// A range covers lines of content, not the wrapped segments those
// lines are drawn as, so its ends go back on the outermost segments
// of their lines: a line that was covered whole stays covered whole.
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
}
}
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
// move under the user; with no cursor on screen the view keeps its own place
// in the content instead.
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
v.SetOriginY(cursorLine - cursorRow)
} else if originLine, ok := v.viewLineOf(origin); ok {
v.SetOriginY(originLine)
}
if cursorOk {
v.cy = cursorLine - v.oy
}
}
// contentPos is a position in a view's content in terms that survive the content
// being wrapped again: which line of it, and which of that line's segments.
type contentPos struct {
line, segment int
ok bool
}
// contentPosOf returns where the given view line sits in the content. Only call
// this with a lock on writeMutex, and with the view lines up to date.
func (v *View) contentPosOf(viewLine int) contentPos {
if viewLine < 0 || viewLine >= len(v.viewLines) {
return contentPos{}
}
return contentPos{
line: v.viewLines[viewLine].linesY,
segment: v.viewLines[viewLine].linesX,
ok: true,
}
}
// viewLineOf returns the view line drawing the given position in the content,
// on the nearest segment its line still has. Only call this with a lock on
// writeMutex, and with the view lines up to date.
func (v *View) viewLineOf(pos contentPos) (int, bool) {
first, last, ok := v.segmentSpanOf(pos)
if !ok {
return 0, false
}
return min(first+pos.segment, last), true
}
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
// outermost segment of its line, so that the range covers that line whole. other
// is the range's other end, which says which way is outward. Both ends have to be
// positions whose lines are drawn, which viewLineOf answers.
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
first, last, _ := v.segmentSpanOf(pos)
if pos.line <= other.line {
return first
}
return last
}
// segmentSpanOf returns the first and last view line drawing the given position's
// line of the content. ok is false when the position was never taken, or its line
// isn't drawn at all.
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
if !pos.ok {
return 0, 0, false
}
return v.viewLineSpanOfBufferLine(pos.line)
}
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
// buffer line, i.e. the first and last segment it is wrapped into. Both are the
// same view line when the line doesn't wrap. ok is false when the line isn't drawn
// at all. Only call this with a lock on writeMutex, and with the view lines up to
// date.
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
first, last := -1, -1
for i, vline := range v.viewLines {
if vline.linesY == bufferLine {
if first == -1 {
first = i
}
last = i
} else if first != -1 {
break
}
}
return first, last, first != -1
}
type searcher struct {
@@ -505,10 +627,23 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
v.rangeSelectStartY = rangeSelectStartY
}
// RangeSelectStartY returns the view line the range selection is anchored on,
// or -1 when there is no range.
func (v *View) RangeSelectStartY() int {
return v.rangeSelectStartY
}
func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
// view showing a plain cursor. A range whose ends are on the same view line is still
// one, which SelectedLineRange alone can't tell you.
func (v *View) HasRangeSelect() bool {
return v.rangeSelectStartY != -1
}
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
if viewHeight >= lineCount {
return 0
@@ -578,6 +713,23 @@ type lineType struct {
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
// asWritten is the text of the line as its writer wrote it, escape sequences
// left out, kept from the first character on that the cells spell differently:
// a tab, which the cells hold as the spaces it fills, or a carriage return,
// which they hold as the overwrite it caused. nil while the cells spell the
// line as it was written, as they do for most lines.
asWritten []byte
}
// textAsWritten returns the line's text as its writer wrote it, escape sequences
// left out. A reader that parses a view's content rather than showing it wants
// this form; the cells' text is for showing.
func (l *lineType) textAsWritten() string {
if l.asWritten != nil {
return string(l.asWritten)
}
return l.cells.String()
}
// trailingFillAttributes describes the fg/bg colors that draw() should
@@ -594,6 +746,9 @@ type cell struct {
width int // number of terminal cells occupied by chr (always 1 or 2)
bgColor, fgColor Attribute
hyperlink string
// the OSC 1717 payload in effect when the cell was written, i.e. what the
// diff renderer said about the diff line this cell is part of
metadata string
}
type cells []cell
@@ -727,7 +882,8 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
if y >= rangeSelectStart && y <= rangeSelectEnd {
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
fgColor = applySelTextColor(fgColor, v.SelTextColor)
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
@@ -835,8 +991,7 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.buf.wx = x
v.buf.wy = y
v.buf.seekWrite(x, y)
// Changing the write position makes a pending newline obsolete
v.buf.pendingNewline = false
@@ -920,6 +1075,35 @@ func (b *viewBuffer) writeCells(cells []cell) {
b.wx += len(cells)
}
// seekWrite moves the write cursor to (x, y). Writing there starts the line over,
// so whatever it kept of its text as written is dropped; the text a line keeps is
// the text written to it from its start. A carriage return continues a line
// instead, and moves the cursor without this (see write).
func (b *viewBuffer) seekWrite(x, y int) {
b.wx = x
b.wy = y
if y < len(b.lines) {
b.lines[y].asWritten = nil
}
}
// startAsWritten begins keeping the current line's text as written (see
// lineType.asWritten), at the first character the cells won't spell the same way.
// Up to here they spell it exactly, so their text is what was written so far.
func (b *viewBuffer) startAsWritten() {
if line := &b.lines[b.wy]; line.asWritten == nil {
line.asWritten = append([]byte{}, line.cells.String()...)
}
}
// noteAsWritten records text the writer wrote to the current line, once the line
// keeps its text as written at all.
func (b *viewBuffer) noteAsWritten(text []byte) {
if line := &b.lines[b.wy]; line.asWritten != nil {
line.asWritten = append(line.asWritten, text...)
}
}
// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
@@ -963,14 +1147,30 @@ func (b *viewBuffer) write(v *View, p []byte) {
finishLine := func() {
b.autoRenderHyperlinksInCurrentLine(v)
// A record that reached the line's end without covering a cell still
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
// or the record of a changed line that is empty, which a renderer emits
// with nothing but the newline after it. Give each a cell of its own, so
// that the line is still recognizable as the diff line it renders rather
// than as nothing at all.
for _, payload := range b.ei.takeOrphanedMetadata() {
b.writeCells([]cell{{metadata: payload}})
}
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
b.ei.metadataConsumed = true
}
}
advanceToNextLine := func() {
b.wx = 0
b.wy++
b.seekWrite(0, b.wy+1)
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
// An OSC 1717 record describes the line it precedes and is never
// closed, so it stops applying at the line's end; a renderer emits a
// fresh one for each line it has something to say about.
b.ei.metadata.Reset()
}
if b.pendingNewline {
@@ -1000,6 +1200,10 @@ func (b *viewBuffer) write(v *View, p []byte) {
b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
// The cells will hold what follows as an overwrite of what came
// before; the text as written keeps the return itself.
b.startAsWritten()
b.noteAsWritten(chr)
b.wx = 0
b.ei.notifyColumnReset()
default:
@@ -1115,8 +1319,18 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
truncateLine := false
isEscape, err := b.ei.parseOne(ch)
// A record that the next one superseded before any cell took it still
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
// a cell of its own, in the order they were emitted, ahead of whatever this
// character produces.
for _, payload := range b.ei.takeOrphanedMetadata() {
cells = append(cells, cell{metadata: payload})
}
if err != nil {
for _, chr := range b.ei.characters() {
characters := b.ei.characters()
for _, chr := range characters {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@@ -1125,6 +1339,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
}
cells = append(cells, c)
}
b.noteAsWritten([]byte(strings.Join(characters, "")))
b.ei.reset()
} else {
repeatCount := 1
@@ -1141,7 +1356,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, []cell{}
return truncateLine, cells
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
@@ -1150,10 +1365,19 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
repeatCount = cf.n
ch = []byte{' '}
width = 1
b.noteAsWritten(bytes.Repeat(ch, repeatCount))
} else if isEscape {
// do not output anything
return truncateLine, nil
// the escape itself outputs nothing, but any cells carrying an
// orphaned record still need writing
if len(cells) == 0 {
return truncateLine, nil
}
return truncateLine, cells
} else if characterEquals(ch, '\t') {
// The cells hold a tab as the spaces it fills; the text as written
// keeps the tab itself.
b.startAsWritten()
b.noteAsWritten(ch)
// fill tab-sized space
tabWidth := v.TabWidth
if tabWidth < 1 {
@@ -1162,14 +1386,20 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
ch = []byte{' '}
width = 1
repeatCount = tabWidth - (x % tabWidth)
} else {
b.noteAsWritten(ch)
}
c := cell{
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
metadata: b.ei.metadata.String(),
chr: string(ch),
width: width,
}
if c.metadata != "" {
b.ei.metadataConsumed = true
}
for range repeatCount {
cells = append(cells, c)
}
@@ -1254,9 +1484,9 @@ func (v *View) CopyContent(from *View) {
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
// different view than the destination — its callers hand content from one
// view to another — and no other code holds two view write locks at once, so
// this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
@@ -1745,6 +1975,139 @@ func (v *View) BufferLines() []string {
return lines
}
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
// to recover which line of which file it came from: the line's text, which can be
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
// records a diff renderer attached to it, which state the answer outright.
type DiffLineContent struct {
// The line's text as its writer wrote it, escape sequences left out, where
// BufferLines gives the text as the cells spell it. The two differ where the
// cells can't spell what was written: a tab, which they hold as the spaces it
// fills, and a carriage return, which they hold as the overwrite it caused.
// git terminates a path containing a space with a tab in a diff header, and a
// parser of the diff has to see the tab.
Text string
// The distinct OSC 1717 payloads carried by the line's cells, in
// left-to-right order. A single-column rendering tags every cell of a line
// with the same payload, so there is one; a side-by-side rendering tags
// each side separately, so a line showing a deletion beside the addition
// that replaces it carries both.
Metadata []string
}
// DiffLineContents returns the per-line material a diff-line reader works from
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
// snapshotted in a single locked pass, so they stay consistent with each other
// and with the buffer they came from even while a re-render rebuilds it.
func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return diffLineContentsFrom(v.buf, 0)
}
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
// progress (see BeginOffscreenRender). A reader deciding where the new content
// should be shown has to work from this: it has to answer before the swap, since
// after the swap the content is already on screen. Returns nil when no re-render
// is underway.
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return nil
}
return diffLineContentsFrom(v.offscreen, 0)
}
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
// follows a re-render as it loads look at each line once, rather than snapshotting
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
// past the lines read so far.
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
return nil
}
return diffLineContentsFrom(v.offscreen, from)
}
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
// has read so far, or 0 when none is underway. It tells a reader waiting for a
// particular line, cheaply, when a screenful below it has arrived too — so that the
// swap shows that line with content under it rather than at the bottom edge of a
// half-filled view.
func (v *View) OffscreenLineCount() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return 0
}
return len(v.offscreen.lines)
}
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
lines := buf.lines[from:]
contents := make([]DiffLineContent, len(lines))
for i := range lines {
line := &lines[i]
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
metadata = append(metadata, c.metadata)
}
}
contents[i] = DiffLineContent{Text: line.textAsWritten(), Metadata: metadata}
}
return contents
}
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
// the index of the corresponding line in the unwrapped internal buffer (as
// returned by BufferLines). Several view lines map to the same buffer line when
// that line wraps. Returns false if the view line is out of range.
func (v *View) BufferLineForViewLine(y int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.bufferLineForViewLine(y)
}
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
// first view line that renders it — the inverse of BufferLineForViewLine, for
// turning a line found by examining the buffer into a line to scroll to or
// select. Returns false if the buffer line isn't rendered into any view line.
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return first, ok
}
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
// the last view line that renders it, which for a line that doesn't wrap is the
// same as the first. It is where the far end of a range goes: a range is over
// buffer lines, so it has to cover the last one of them to its final segment
// rather than stopping where that line begins.
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return last, ok
}
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
@@ -1771,6 +2134,9 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return len(v.buf.lines)
}
@@ -1980,16 +2346,31 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// IsLineVisible reports whether the given view line is one of those on screen.
func (v *View) IsLineVisible(viewLine int) bool {
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.buf.lines) == 0 {
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
return ""
}
return v.lineContentAtIdx(v.SelectedLineIdx())
return v.lineContentAtIdx(idx)
}
// expected to only be used in tests
@@ -2004,8 +2385,17 @@ func (v *View) SelectedLines() []string {
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; i++ {
lines = append(lines, v.lineContentAtIdx(i))
// The selection is in view lines, which count the segments a wrapped line
// is drawn as; a line the selection covers several segments of is still
// the one line it is.
idx, ok := v.bufferLineForViewLine(i)
if !ok || idx == previous {
continue
}
previous = idx
lines = append(lines, v.lineContentAtIdx(idx))
}
return lines
@@ -2015,6 +2405,19 @@ func (v *View) lineContentAtIdx(idx int) string {
return v.buf.lines[idx].cells.String()
}
// bufferLineForViewLine maps a view line index, which counts the wrapped
// segments of the lines it draws, to the index of the line of content it is a
// segment of. Only call this with a lock on writeMutex.
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
return 0, false
}
return v.viewLines[y].linesY, true
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()
@@ -2040,6 +2443,23 @@ func (v *View) SelectedLineRange() (int, int) {
return start, end
}
// SelectedBufferLineRange is SelectedLineRange in buffer lines (see
// BufferLineForViewLine): the first and last line of the view's content that the
// selection covers, however the view wraps them. ok is false when the selection
// isn't on the content, which happens when there is none.
func (v *View) SelectedBufferLineRange() (int, int, bool) {
first, last := v.SelectedLineRange()
firstBufferLine, ok := v.BufferLineForViewLine(first)
if !ok {
return 0, 0, false
}
lastBufferLine, ok := v.BufferLineForViewLine(last)
if !ok {
return 0, 0, false
}
return firstBufferLine, lastBufferLine, true
}
func (v *View) RenderTextArea() {
v.Clear()
fmt.Fprint(v, v.TextArea.GetContent())
@@ -2085,8 +2505,7 @@ func (v *View) ClearTextArea() {
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.buf.wx = 0
v.buf.wy = y
v.SetWritePos(0, y)
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
+319
View File
@@ -113,6 +113,99 @@ func TestWriteString(t *testing.T) {
}
}
func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
// The trailing newline is held back until more content arrives, so that the
// view doesn't end in an empty line.
v.writeString("a\nb\n")
v.OverwriteLines(0, "x")
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
}
// diffLineTexts returns the Text of each of the given contents.
func diffLineTexts(contents []DiffLineContent) []string {
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
}
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
tests := []struct {
name string
stringsToWrite []string
expectedShown []string
expectedAsWritten []string
}{
{
name: "a line the cells spell as written",
stringsToWrite: []string{"abc\n"},
expectedShown: []string{"abc"},
expectedAsWritten: []string{"abc"},
},
{
name: "a tab is kept rather than the spaces it fills",
stringsToWrite: []string{"a\tb\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "a carriage return is kept rather than the overwrite it causes",
stringsToWrite: []string{"abc\rde\n"},
expectedShown: []string{"dec"},
expectedAsWritten: []string{"abc\rde"},
},
{
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
name: "escape sequences are left out",
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
expectedShown: []string{"+foo"},
expectedAsWritten: []string{"+foo\r"},
},
{
// ConPTY writes a run of spaces as a cursor-forward escape.
name: "a cursor-forward escape stands for the spaces it skips",
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
expectedShown: []string{" a b"},
expectedAsWritten: []string{"\ta b"},
},
{
name: "a line written in two parts",
stringsToWrite: []string{"a\t", "b\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "only the lines with a tab or a return are kept separately",
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
expectedShown: []string{"x", "y z", "w"},
expectedAsWritten: []string{"x", "y\tz", "w"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
for _, s := range test.stringsToWrite {
v.writeString(s)
}
assert.Equal(t, test.expectedShown, v.BufferLines())
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
})
}
}
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
v.writeString("a\tb\nc\td")
// Overwriting a line starts it over: what it kept of its earlier text goes,
// and the line below is left alone.
v.OverwriteLines(0, "xy")
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
}
func TestUpdatedCursorAndOrigin(t *testing.T) {
tests := []struct {
prevOrigin int
@@ -158,6 +251,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
func osc1717(payload string) string {
return "\x1b]1717;" + payload + "\x1b\\"
}
func TestDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer prefixes each line it renders with a record naming the
// file and the line's position in it: version;type;new-line;old-line;file.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "line1",
osc1717("1;d;2;2;foo.txt") + "old2",
osc1717("1;a;2;;foo.txt") + "new2",
"@@ a hunk header, which carries no record @@",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
// The record of the line before doesn't bleed onto this one.
{Text: "@@ a hunk header, which carries no record @@"},
}, v.DiffLineContents())
}
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A side-by-side renderer puts two diff lines on one rendered line, and so
// emits a record before each half.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
}, "\n"))
assert.Equal(t, []DiffLineContent{
// The two halves of a context line are the same diff line, stated twice.
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A line that gocui wraps is still one buffer line, so its record covers
// every view line it is displayed on.
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
assert.Equal(t, []DiffLineContent{
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
assert.Equal(t, 3, v.ViewLinesHeight())
for viewLine := range 3 {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, 0, bufferLine)
}
}
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString(strings.Join([]string{
// A banner announcing a file and its first hunk at once carries both
// records back to back.
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
// So does a modification whose deletion and addition are collapsed into
// a single rendered line.
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
// A changed line that is empty is rendered as its record and nothing else.
osc1717("1;a;6;;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, []DiffLineContent{
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer announces itself with a version-only record before the
// diff. It must leave no trace: no visible bytes, no line of its own, and
// above all no record on the line that follows it.
v.writeString(osc1717("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc1717("1;a;1;;foo.txt") + "added",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "diff --git a/foo.txt b/foo.txt"},
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
}
// 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.
@@ -204,6 +398,81 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
func TestBufferLineForViewLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, wantBufferLine, bufferLine)
}
_, ok := v.BufferLineForViewLine(5)
assert.False(t, ok)
_, ok = v.BufferLineForViewLine(-1)
assert.False(t, ok)
}
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the first of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 1, 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestLastViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the last of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 3, 4} {
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.LastViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestSelectedBufferLineRange(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
_, _, ok := v.SelectedBufferLineRange()
assert.False(t, ok)
// Buffer line 1 wraps into view lines 1, 2 and 3.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
// A range from the middle of the wrapped line to the line below it covers both
// lines.
v.FocusPoint(0, 2, false)
v.SetRangeSelectStart(4)
first, last, ok := v.SelectedBufferLineRange()
assert.True(t, ok)
assert.Equal(t, 1, first)
assert.Equal(t, 2, last)
}
// 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
@@ -820,3 +1089,53 @@ func TestApplySelTextColor(t *testing.T) {
})
}
}
// A view that wraps draws one line of its content as several view lines, and the
// cursor and the range anchor count those. What is asked about a selection is
// which lines of the content it covers, so those are what it has to be reported
// in.
func TestSelectedLinesOfWrappedContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// "a line that wraps" takes two view lines, so the four lines of content are
// drawn as five: "one", "two", "a line th", "at wraps", "four".
v.writeString("one\ntwo\na line that wraps\nfour\n")
assert.Equal(t, 5, v.ViewLinesHeight())
// The cursor on the wrapped line's second half is on that line.
v.FocusPoint(0, 3, false)
assert.Equal(t, "a line that wraps", v.SelectedLine())
// A range over both halves of the wrapped line covers one line of content.
v.SetRangeSelectStart(2)
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
// Resizing a view throws away the wrapping of its content and wraps it again for
// the new width, which moves every line of it to a different view line. The
// positions into the view count view lines, so they all have to come along.
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
g := &Gui{}
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// Two wrapping lines, with a single line between them: eight view lines for
// five lines of content.
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
assert.Equal(t, 8, v.ViewLinesHeight())
// A range over the whole of the second wrapping line, which is drawn as view
// lines 4 to 6.
v.SetRangeSelectStart(4)
v.FocusPoint(0, 6, false)
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
// Widen the view so that nothing wraps any more.
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
assert.Equal(t, 5, v.ViewLinesHeight())
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
}
+14 -3
View File
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.updateSelectionHighlights()
self.UpdateSelectionHighlights()
c.HandleFocus(opts)
}
// updateSelectionHighlights re-derives which views draw a selection, and which of
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) updateSelectionHighlights() {
func (self *ContextMgr) UpdateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
@@ -399,3 +399,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
panic("context not in stack")
}
// IsInStack reports whether the given context is on the stack at all, for callers
// that can't otherwise know and would make NextInStack panic.
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()
})
}
+27 -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
focusedMainViewDiffSource types.FocusedMainViewDiffSource
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{}
@@ -122,6 +121,14 @@ func (self *BaseContext) HasSelectableContent() bool {
return self.hasSelectableContent
}
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
// list length either: the main panes, which can only tell by reading the diff they
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
// ContextMgr.UpdateSelectionHighlights).
func (self *BaseContext) SetHasSelectableContent(value bool) {
self.hasSelectableContent = value
}
func (self *BaseContext) GetKey() types.ContextKey {
return self.key
}
@@ -153,7 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.focusedMainViewDiffSource = nil
self.onRenderToMainFn = nil
}
@@ -175,12 +182,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) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
if source != nil {
if self.focusedMainViewDiffSource != nil {
panic("only one controller is allowed to set the focused main view diff source")
}
self.onClickFocusedMainViewFn = fn
self.focusedMainViewDiffSource = source
}
}
@@ -192,8 +199,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.focusedMainViewDiffSource
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
+17 -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,16 @@ 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 gives the two ends to diff for a ref, or for a range of them: a
// range runs from the parent of its first ref to its last, a single ref from its own
// parent to itself.
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(
+78 -7
View File
@@ -8,9 +8,78 @@ import (
type MainContext struct {
*SimpleContext
*SearchTrait
diffSelect types.DiffSelectState
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
// drag that follows can anchor its range there. The click may have selected a whole
// hunk, whose range anchor is the block's far end, so the clicked line can't be
// read back from the view.
dragAnchorViewLine int
// selectableContentRenderKey names the render whose content HasSelectableContent
// was worked out from. What there is to select is a property of the content, so an
// answer about the content of another render says nothing about this one.
selectableContentRenderKey string
// contentIsDiff says the render this pane was last given is the diff the panel
// beneath offers here, rather than a message or an explanation (see
// types.ContentIsDiff). It is known as the render is asked for, well before there
// is any content to look at.
contentIsDiff bool
}
var _ types.ISearchableContext = (*MainContext)(nil)
var (
_ types.ISearchableContext = (*MainContext)(nil)
_ types.DiffPaneContext = (*MainContext)(nil)
)
// DiffSelectState returns the focused main view's selection mode state, for the
// controllers to read and mutate directly.
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
return &self.diffSelect
}
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
// line, no range — for whenever it is established from scratch rather than moved. The
// view's range anchor is cleared too, so the next render highlights the cursor line
// only.
func (self *MainContext) ResetDiffSelectMode() {
self.diffSelect.Mode = types.DiffSelectModeLine
self.diffSelect.RangeIsSticky = false
self.diffSelect.UserEnabledHunkMode = false
self.GetView().CancelRangeSelect()
}
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that 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
}
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
// selectableContentRenderKey).
func (self *MainContext) SelectableContentRenderKey() string {
return self.selectableContentRenderKey
}
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
func (self *MainContext) SetSelectableContentRenderKey(key string) {
self.selectableContentRenderKey = key
}
// ContentIsDiff reports whether this pane is showing the panel's diff (see
// contentIsDiff).
func (self *MainContext) ContentIsDiff() bool {
return self.contentIsDiff
}
// SetContentIsDiff records what kind of render the pane is being given.
func (self *MainContext) SetContentIsDiff(value bool) {
self.contentIsDiff = value
}
func NewMainContext(
view *gocui.View,
@@ -21,12 +90,11 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HasSelectableContent: false,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -38,5 +106,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
return nil
}
// When selecting a search result, collapse a range selection (whether sticky or not)
// or a hunk selection to just the matching line.
func (self *MainContext) OnSearchSelect(int) {
self.ResetDiffSelectMode()
}
+7 -2
View File
@@ -14,10 +14,15 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
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 },
+3 -4
View File
@@ -67,7 +67,8 @@ func (gui *Gui) resetHelpersAndControllers() {
worktreeHelper,
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() {
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
DiffLine: diffLineHelper,
Repos: reposHelper,
RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
@@ -176,7 +178,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)
@@ -313,13 +314,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.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
+2 -2
View File
@@ -19,11 +19,11 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return nil
}
+16 -13
View File
@@ -175,9 +175,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, mode)
task := types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
@@ -191,11 +192,23 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
}
}
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the commit's diff for the given files. Both panes show the same
// diff here — the secondary one shows the custom patch built from it, which is not a
// diff of the commit — so which pane asks makes no difference.
func (self *CommitFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
from, to := self.context().GetFromAndToForDiff()
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
}
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
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, git_commands.DiffModePlain)
diff, err := cmdObj.RunWithOutput()
if err != nil {
return err
@@ -606,16 +619,6 @@ 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})
}
return nil
}
}
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
@@ -83,6 +83,11 @@ 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 diff is about to be rendered again with more or less context around
// each change, which reads as the lines you were looking at moving up or down
// the view; keep them where they are instead.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentContext.HandleRenderToMain()
}
return nil
+37 -35
View File
@@ -21,6 +21,9 @@ type FilesController struct {
baseController
*ListControllerTrait[*filetree.FileNode]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *WorkingTreeDiffActions
}
var _ types.IController = &FilesController{}
@@ -36,6 +39,7 @@ func NewFilesController(
c.Contexts().Files.GetSelected,
c.Contexts().Files.GetSelectedItems,
),
diffActions: NewWorkingTreeDiffActions(c),
}
}
@@ -348,7 +352,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()}, git_commands.DiffModeRendered)
prefix := message + "\n\n"
if node.File.ShortStatus == "DU" {
prefix += self.c.Tr.MergeConflictIncomingDiff
@@ -366,58 +370,56 @@ 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()
// The unstaged side of a file's diff is shown in the main pane and the staged side
// in the secondary one, each only where there is a side to show — so a side is
// always in the same place, whatever the file happens to have. A file with nothing
// unstaged therefore shows its staged changes in the secondary pane, which then has
// the whole section to itself. Configured to always split, both panes are shown
// whether or not there is anything on either side.
alwaysSplit := self.c.UserConfig().Gui.SplitDiff == "always"
showStaged := node.GetHasStagedChanges() || alwaysSplit
showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged
// While the main view is focused to act on this diff, it may have to be git's own
// rather than the diff renderer's; both panes have to agree about that.
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
}
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
title = self.c.Tr.UnstagedChanges
if showUnstaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths)
refreshOpts.Main = &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.UnstagedChanges,
NothingToActOn: !node.GetHasUnstagedChanges(),
}
}
if showStaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths)
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
NothingToActOn: !node.GetHasStagedChanges(),
}
}
self.c.RenderToMainViews(refreshOpts)
}
func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
})
}
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})
}
return nil
}
}
// if we are dealing with a status for which there is no key in this map,
// then we won't optimistically render: we'll just let `git status` tell
// us what the new status is.
@@ -99,21 +99,27 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingSt
self.modeHelper.SetSuppressWorkingTreeStateMode(true)
}
self.c.OnWorker(func(task gocui.Task) error {
// End the block and restore the mode indicator once the operation and its
// refresh have applied their UI updates: OnUIThread queues this after the
// refresh's model bounces and Then (which RefreshFromWorker has already
// enqueued by the time f returns), so the replayed keys act on the
// refreshed state and any resulting working tree state shows correctly.
defer self.c.OnUIThread(func() error {
if opts.HideWorkingTreeState {
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
}
return self.c.GocuiGui().EndBlockingEvents()
})
defer self.endBlockingInput(opts.HideWorkingTreeState)
return self.WithWaitingStatusImpl(opts.Message, f, task)
})
}
// endBlockingInput lets input through again once the operation and its refresh
// have applied their UI updates, and restores the mode indicator with it.
// OnUIThread queues this after the refresh's model bounces and Then (which
// RefreshFromWorker has already enqueued by the time the operation returns), so
// the replayed keys act on the refreshed state and any resulting working tree
// state shows correctly.
func (self *AppStatusHelper) endBlockingInput(hideWorkingTreeState bool) {
self.c.OnUIThread(func() error {
if hideWorkingTreeState {
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
}
self.c.GocuiGui().EndBlockingEvents()
return nil
})
}
func (self *AppStatusHelper) HasStatus() bool {
return self.statusMgr().HasStatus()
}
+31 -9
View File
@@ -14,12 +14,14 @@ import (
)
type DiffHelper struct {
c *HelperCommon
c *HelperCommon
diffLineHelper *DiffLineHelper
}
func NewDiffHelper(c *HelperCommon) *DiffHelper {
func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper {
return &DiffHelper{
c: c,
c: c,
diffLineHelper: diffLineHelper,
}
}
@@ -53,6 +55,8 @@ func (self *DiffHelper) DiffArgs() []string {
// 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 {
mode := self.diffLineHelper.MainViewDiffMode()
if refRange != nil {
from, to := refRange.From, refRange.To
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
@@ -72,13 +76,26 @@ 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, mode)
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode)
}
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode)
return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
}
// PlainDiffBetweenRefs returns the diff of the given files between two refs as git
// writes it, without colour or a diff renderer's involvement — what a panel showing
// a commit's diff hands out as the diff behind its rendering (see
// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of
// the same two ends the main view is showing.
func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string {
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
// An error means there is no diff to be had, which for our purposes is the same
// as an empty one.
diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain).RunWithOutput()
return diff
}
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
@@ -107,19 +124,24 @@ func (self *DiffHelper) RenderToMainAgain() {
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
// Whatever changed can make the diff come out differently, such as a new
// renderer laying it out its own way, so the line you were looking at could
// end up anywhere in the view; keep it in front of you.
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentSide.HandleRenderToMain()
}
}
func (self *DiffHelper) RenderDiff() {
args := self.DiffArgs()
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered)
prefix := style.FgMagenta.Sprintf(
"%s %s\n\n",
self.c.Tr.ShowingGitDiff,
"git diff "+strings.Join(args, " "),
)
task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
task := types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, git_commands.DiffModeRendered)
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
@@ -0,0 +1,177 @@
package helpers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type DiffLineHelper struct {
c *HelperCommon
// What the probe said about the diff renderer that rendererSignature names, or nil
// before it has been asked about any (see diffRendererEmitsMetadata).
rendererEmitsMetadata *bool
rendererSignature string
}
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
return &DiffLineHelper{c: c}
}
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
// of the diff row at the given (wrapped) view line of the given view. It is the
// seam every consumer of a diff row goes through, so that how we recover that
// identity can change without them noticing.
//
// There are two ways, and which one is used is settled for the rendering as a
// whole (see renderingStatesDiffLines). A diff renderer that speaks the OSC 1717
// protocol states the identity of each line it renders. That is the only way to
// recover it from a rendering that doesn't look like a diff any more (columns, or
// +/- markers replaced by colour), and a row such a renderer says nothing about
// has no identity. Otherwise we parse the view's contents as a unified diff; this
// works for the renderings that keep a diff's structure (no renderer, `git diff
// --color`, a renderer that only colorizes) and fails for the rest.
//
// ok is false when the row's identity can't be recovered, in which case the
// caller must not act on the line at all.
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
return identities[0], true
}
// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view
// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't
// settle for the line the row leads with: an end of a selection covers its whole
// row, so where a rendering puts a modification's two halves side by side it covers
// both of them. ok is false when the row's identity can't be recovered at all.
func (self *DiffLineHelper) diffLineIdentitiesAt(
view *gocui.View, viewLineIdx int,
) ([]types.DiffLineInfo, bool) {
// The cursor and clicks land on a view line, which counts wrapped segments;
// the contents are indexed by unwrapped buffer line.
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
if !ok {
return nil, false
}
contents := view.DiffLineContents()
if bufferLineIdx >= len(contents) {
return nil, false
}
if renderingStatesDiffLines(contents) {
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
return identities, true
}
return nil, false
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
if !ok {
return nil, false
}
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
}
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
// records state, left to right. A row can carry more than one record, when the
// rendering puts two diff lines on it (a side-by-side row shows a deletion and the
// addition replacing it). Which of them a reader is after depends on the reader: the
// one the row leads with is the row's own identity (see GetDiffLineInfo and
// resolveDiffLines), while a reader looking for a particular line has to consider
// them all, since which of a modification's two halves leads a row is up to the
// rendering.
func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo {
return self.diffLineInfos(parseDiffLineRecords(metadata))
}
// diffLineInfoFromRecords recovers a row's own identity from the records the diff
// renderer stated for it. That is the line the row leads with, of the ones
// diffLineIdentitiesFromRecords finds on it. ok is false when the row carries no
// record we understand.
func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) {
identities := self.diffLineIdentitiesFromRecords(metadata)
if len(identities) == 0 {
return types.DiffLineInfo{}, false
}
return identities[0], true
}
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
// be recovered at all — the element of the table resolveDiffLines produces.
type resolvedDiffLine struct {
info types.DiffLineInfo
ok bool
}
// resolveDiffLines recovers the identity of every row of a rendered diff in one
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
// whole-buffer scans (which change lines are where, which file each row belongs
// to). A row's identity is the line it leads with, of those resolveDiffLineIdentities
// finds on it.
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
resolved := make([]resolvedDiffLine, len(contents))
for i, identities := range self.resolveDiffLineIdentities(contents) {
if len(identities) > 0 {
resolved[i] = resolvedDiffLine{identities[0], true}
}
}
return resolved
}
// resolveDiffLineIdentities recovers every diff line each row of a rendered diff
// shows, in one pass, indexed 1:1 with contents. It reads the rendering the way
// GetDiffLineInfo does, by the renderer's records or by parsing it as a unified diff
// (see parseDiffLineIdentities), and is the form of the batch resolver for the
// readers that can't settle for the line a row leads with: looking for a remembered
// line in a new rendering has to consider both halves of a modification, since a
// side-by-side row leads with the deletion whose addition was what got remembered
// under a unified one.
func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo {
identities := make([][]types.DiffLineInfo, len(contents))
for i, parsed := range parseDiffLineIdentities(contents) {
if len(parsed) > 0 {
identities[i] = self.diffLineInfos(parsed)
}
}
return identities
}
// diffLineInfo turns a parser's result into the absolute-path identity consumers
// work with. The path arrives repo-relative from the diff header, but a renderer
// states it however it likes, absolute paths included.
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed)
}
// diffLineInfos is diffLineInfo over every line of a row.
func (self *DiffLineHelper) diffLineInfos(parsed []parsedDiffLine) []types.DiffLineInfo {
infos := make([]types.DiffLineInfo, len(parsed))
for i, line := range parsed {
infos[i] = self.diffLineInfo(line)
}
return infos
}
// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't
// ask which repo we are in where they run: a repo switch replaces it, so only the UI
// thread may read it.
func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo {
path := parsed.Path
if !filepath.IsAbs(path) {
path = filepath.Join(worktreePath, path)
}
return types.DiffLineInfo{
Path: path,
Type: parsed.Type,
NewLine: parsed.NewLine,
OldLine: parsed.OldLine,
}
}
@@ -0,0 +1,452 @@
package helpers
import (
"regexp"
"slices"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"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 "
// submodulePrefix opens the line a submodule's section of a diff starts with, which
// stands in for the "diff --git" header a file of the repo gets.
const submodulePrefix = "Submodule "
// submoduleSectionPattern matches that line and captures the submodule's path. git
// writes one of two kinds: the commit the submodule is checked out at has moved
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
// place of the colon where the two commits can't both be read), or its working tree
// is dirty ("Submodule sub contains untracked content").
//
// The path is captured greedily: git writes it unquoted, so a path that itself ends
// in something reading like a range of commits is told apart by taking the last such
// range on the line.
var submoduleSectionPattern = regexp.MustCompile(
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
// Path is the path as the diff header spells it, i.e. relative to the repo root;
// the caller turns it into the absolute path of types.DiffLineInfo.
type parsedDiffLine struct {
Path string
Type types.DiffLineType
NewLine int
OldLine int
}
// bufferLineParse is the parser's result for one buffer line: the recovered
// identity, and whether the line could be resolved at all (false for a line in
// an unparseable section, or outside any file section).
type bufferLineParse struct {
parsed parsedDiffLine
ok bool
}
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
// parsing the view's decolorized contents.
//
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
// resolve. A commit's diff spans several files, so we isolate the file section
// containing targetIdx and parse just that one (see parseFileSection). Use this
// for a single line, e.g. the one under the cursor; to resolve every line of a
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
//
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
// because the diff renderer restructured it, so that the caller can fall back.
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], end == len(bufferLines))[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 a whole buffer:
// resolving line by line would re-parse a section once per line of it — O(n²) on
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
// with bufferLines; a line in an unparseable section, or above the first one, is
// left ok=false.
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(bufferLines))
for i := 0; i < len(bufferLines); {
if !startsFileSection(bufferLines[i]) {
i++ // in no file section; leave it unresolved
continue
}
end := fileSectionEnd(bufferLines, i)
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
i = end
}
return result
}
// diffLineTexts extracts the text of each rendered row — the material the buffer
// parser works on.
func diffLineTexts(contents []gocui.DiffLineContent) []string {
texts := make([]string, len(contents))
for i, content := range contents {
texts[i] = content.Text
}
return texts
}
// renderingStatesDiffLines reports whether the renderer stated, for at least one row
// of the rendering, which diff line it shows. The version-only record a renderer
// announces the protocol with names no line, and doesn't count.
//
// The answer settles how the whole rendering is read. A renderer that states its
// lines lays the diff out as it likes, so its text is no unified diff and must not be
// parsed as one, not even for the rows it says nothing about. Such a row can read
// like a diff header when the file being diffed is itself a diff; the parser would
// take that for the start of a file section and place every untagged row below it
// in a file the diff doesn't have. So the rows a renderer leaves untagged (dividers,
// padding) have no identity, as the protocol has it. A rendering without any record
// is a diff that describes itself, and is parsed as one.
func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
return slices.ContainsFunc(contents, func(content gocui.DiffLineContent) bool {
return slices.ContainsFunc(content.Metadata, func(record string) bool {
_, ok := parseDiffLineMetadata(record)
return ok
})
})
}
// parseDiffLineRecords parses the records a row carries, left to right, leaving out
// the ones we don't understand. A row carries more than one when the rendering puts
// two diff lines on it, as a side-by-side row does with a deletion and the addition
// replacing it.
func parseDiffLineRecords(metadata []string) []parsedDiffLine {
parsed := make([]parsedDiffLine, 0, len(metadata))
for _, record := range metadata {
if line, ok := parseDiffLineMetadata(record); ok {
parsed = append(parsed, line)
}
}
return parsed
}
// parseDiffLineIdentities recovers, for every row of a rendering, the diff lines it
// shows, indexed 1:1 with contents; a row that shows none we can place gets an empty
// entry. The rendering is read the way renderingStatesDiffLines settles: by the
// renderer's records, every one a row carries, or else by parsing the rendering as a
// unified diff, where each row shows one line. Each file's section is parsed once;
// resolving row by row would re-run that parse once per row, O(n²) on a large
// single-file diff.
func parseDiffLineIdentities(contents []gocui.DiffLineContent) [][]parsedDiffLine {
identities := make([][]parsedDiffLine, len(contents))
if renderingStatesDiffLines(contents) {
for i, content := range contents {
if parsed := parseDiffLineRecords(content.Metadata); len(parsed) > 0 {
identities[i] = parsed
}
}
return identities
}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
if parsed.ok {
identities[i] = []parsedDiffLine{parsed.parsed}
}
}
return identities
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest line starting a section at or above it, up to
// where that section ends. start is -1 when targetIdx is above the first file
// section, or below the end of the last one that begins above it.
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
for start = targetIdx; start >= 0; start-- {
if !startsFileSection(bufferLines[start]) {
continue
}
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
return start, end
}
return -1, -1
}
return -1, -1
}
// fileSectionEnd returns the line the file section beginning at start ends before.
//
// A file's section runs to the next one, since every line between them is part of
// its diff. A submodule's runs only as far as what git writes for it — the line
// naming it, and the log of the commits it moved over — because the lines after
// that need not belong to any section at all. A diff renderer's output has no
// "diff --git" line to stop at, and the rows it puts between one file and the next
// belong to neither.
func fileSectionEnd(bufferLines []string, start int) int {
if submodulePath(bufferLines[start]) != "" {
end := start + 1
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
end++
}
return end
}
for i := start + 1; i < len(bufferLines); i++ {
if startsFileSection(bufferLines[i]) {
return i
}
}
return len(bufferLines)
}
// startsFileSection reports whether the line opens a section of a diff: git's header
// for a file of the repo, or the line a submodule's section begins with.
func startsFileSection(line string) bool {
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
}
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
// a submodule's header, which it writes as two spaces, the direction the commit was
// moved in, and the commit's subject.
func isSubmoduleLogLine(line string) bool {
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
}
// submodulePath returns the submodule whose section the given line opens, and "" for
// every other line.
//
// A submodule gets no "diff --git" header and no hunks: git states which commits it
// moved between and lists them, so that one line is all there is to take the path
// from. The prefix is tested first so that the pattern is run over next to no lines
// of a diff.
func submodulePath(line string) string {
if !strings.HasPrefix(line, submodulePrefix) {
return ""
}
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
return match[1]
}
return ""
}
// parseFileSection parses one file's diff section (fileLines, starting at the line
// that opens it) a single time and returns the identity of 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 the old/new line numbers fall out of
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
// it comes out as a header of the submodule, which is what they are: what git states
// there is which commits it moved between, not lines of a file.
//
// 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, and acting on a mis-parse would land us on the wrong line, so
// the caller should fall back.
//
// endsTheBuffer says the section runs to the end of what we were given. That is
// where a diff we have only part of breaks off. A long one is read a screenful
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
// than its header declares until the reading is done. Insisting on the whole
// hunk there would leave every line of the file unresolved while the diff is the
// one on screen, so a section in that position is held to what has arrived.
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
result := make([]bufferLineParse, len(fileLines))
relPath := pathFromDiffHeader(fileLines)
if relPath == "" {
return result
}
p := patch.Parse(strings.Join(fileLines, "\n"))
isWellFormed := p.IsWellFormed
if endsTheBuffer {
isWellFormed = p.IsWellFormedSoFar
}
if !isWellFormed() {
return result
}
patchLines := p.Lines()
for i := range fileLines {
if i >= len(patchLines) {
break
}
parsed := parsedDiffLine{
Path: 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 diff section. A
// submodule's section states its path in the line it opens with. For a file of the
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
// there are no such lines at all (a pure rename, which has no hunks).
func pathFromDiffHeader(fileLines []string) string {
if path := submodulePath(fileLines[0]); path != "" {
return path
}
var oldPath, newPath string
for _, line := range fileLines {
if strings.HasPrefix(line, "@@") {
break // past the header
}
switch {
case strings.HasPrefix(line, "+++ "):
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
case strings.HasPrefix(line, "--- "):
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
}
}
if newPath != "" && newPath != "/dev/null" {
return newPath
}
if oldPath != "" && oldPath != "/dev/null" {
return oldPath
}
return pathFromDiffGitLine(fileLines[0])
}
// pathFromDiffHeaderField decodes one path field of a diff header — the part
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
// into the repo-relative path it names.
//
// git spells such a field in three ways: plain; terminated by a tab, when the
// path contains a space; or C-quoted as a whole, when the path contains
// characters git won't print raw — which, with core.quotePath enabled (the
// default), includes every non-ASCII byte, so `café` arrives as
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
// so strconv decodes it for us.
//
// Returns "" for a quoted field we can't decode: better to resolve nothing than
// to point a consumer at a path that doesn't exist.
func pathFromDiffHeaderField(field string) string {
field = strings.TrimSuffix(field, "\t")
if strings.HasPrefix(field, `"`) {
unquoted, err := strconv.Unquote(field)
if err != nil {
return ""
}
field = unquoted
}
return stripDiffPathPrefix(field)
}
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
// so they are always there.
func stripDiffPathPrefix(path string) string {
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
// diff renderer states which line of which file it is rendering. The v1 payload
// is positional and ';'-delimited:
//
// version;type;new-line;old-line;file
//
// The file comes last so that it may itself contain a ';'. The old-file line is
// empty unless the line is a deletion, the only kind that needs it, and the
// new-file line is empty on a file header, the one kind that has no line.
//
// ok is false for a payload of an unknown version or shape, so that the caller
// can fall back to reading the rendered text.
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{Path: 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
}
}
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
// line, where the two paths are separated by a space and either may be quoted.
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
// are unambiguous and we only get here when they are absent.
func pathFromDiffGitLine(line string) string {
rest := strings.TrimPrefix(line, diffFilePrefix)
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
return ""
}
@@ -0,0 +1,480 @@
package helpers
import (
"slices"
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gocui"
"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{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
{"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
{"context line", 5, parsedDiffLine{Path: "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{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", 7, parsedDiffLine{Path: "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{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
{"second addition", 16, parsedDiffLine{Path: "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 TestParseDiffLineFromBufferRename(t *testing.T) {
// A rename with no content change has no hunks and no +++/--- lines, so the
// path has to come from the "diff --git" line; a rename with a content
// change has them, and they carry the new path.
pureRename := strings.Split(`diff --git a/old.go b/new.go
similarity index 100%
rename from old.go
rename to new.go`, "\n")
result, ok := parseDiffLineFromBuffer(pureRename, 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
renameWithModification := strings.Split(`diff --git a/old.go b/new.go
similarity index 62%
rename from old.go
rename to new.go
index 1111111..2222222 100644
--- a/old.go
+++ b/new.go
@@ -1,2 +1,2 @@
apple
-grape
+kiwi`, "\n")
result, ok = parseDiffLineFromBuffer(renameWithModification, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
}
func TestParseDiffLineFromBufferDeletedFile(t *testing.T) {
// The new path is /dev/null, so the identity comes from the old path.
deletedFile := strings.Split(`diff --git a/gone.go b/gone.go
deleted file mode 100644
index 1111111..0000000
--- a/gone.go
+++ /dev/null
@@ -1,2 +0,0 @@
-apple
-grape`, "\n")
result, ok := parseDiffLineFromBuffer(deletedFile, 7)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
}
func TestParseDiffLineFromBufferSubmodule(t *testing.T) {
// A submodule has no "diff --git" header and no hunks: git opens its section with
// the commits it moved between and lists them below. So the section ends the one
// above it, and every row of it belongs to the submodule as a whole.
withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt
index 1111111..2222222 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,3 @@
hello
world
+world
Submodule modules/xyz a32f27c..2d9f921:
> bump the thing`, "\n")
for _, targetIdx := range []int{8, 9} {
result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx)
assert.True(t, ok)
assert.Equal(t,
parsedDiffLine{Path: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1},
result, "line %d", targetIdx)
}
result, ok := parseDiffLineFromBuffer(withSubmodule, 7)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result)
}
func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) {
// A diff renderer passes the lines git writes for a submodule through as they
// are, while printing nothing below them that a section could end at. The
// section has to end where what git writes for the submodule ends all the same:
// the rows below belong to the other files of the diff, and can only be placed
// by the records the renderer states for them.
rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921:
> bump the thing
products/a.txt
one
two`, "\n")
all := parseAllDiffLinesFromBuffer(rendered)
assert.Equal(t, "modules/xyz", all[0].parsed.Path)
assert.Equal(t, "modules/xyz", all[1].parsed.Path)
for i := 2; i < len(rendered); i++ {
assert.False(t, all[i].ok, "line %d: %q", i, rendered[i])
}
}
func TestSubmodulePath(t *testing.T) {
scenarios := []struct {
name string
line string
expected string
}{
{"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"},
{"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"},
{"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"},
{"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"},
{"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"},
{"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"},
{"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"},
{"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"},
// git writes the path unquoted, so one with a space in it, or one ending in
// something that reads like a range of commits, is told apart by matching the
// last range on the line.
{"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"},
{"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"},
// A line of a file that reads like one of these is indented by the column the
// diff states the line's side in, so it cannot be mistaken for one.
{"a line of a file", " Submodule modules/xyz contains modified content", ""},
{"something else entirely", "Submodule support was added", ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, submodulePath(s.line))
})
}
}
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
// A rendering with no "diff --git" line can't be parsed, so the caller falls
// back rather than acting on the line.
bufferLines := []string{"some", "lines", "that", "are not a diff"}
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
assert.False(t, ok)
}
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
// A diff renderer that moves the line numbers into a gutter keeps the diff
// and 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 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 TestParseDiffLineFromBufferReadInPart(t *testing.T) {
lines := strings.Split(twoFileDiff, "\n")
// A long diff is read a screenful at a time, so the buffer breaks off part way
// through a hunk. The lines that did arrive are resolved all the same, since
// holding out for the whole hunk would leave the diff on screen with nothing to
// act on.
cutShort := lines[:len(lines)-1]
result, ok := parseDiffLineFromBuffer(cutShort, 15)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result)
// Only the section the buffer breaks off in is read that way. One that another
// section follows is all there, so a hunk short of what its header declares means
// the rendering restructured the diff, and none of it is resolved.
shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...)
_, ok = parseDiffLineFromBuffer(shortFirstSection, 5)
assert.False(t, ok)
}
func TestPathFromDiffHeaderField(t *testing.T) {
scenarios := []struct {
name string
field string
expected string
}{
{"new side", "b/file.go", "file.go"},
{"old side", "a/file.go", "file.go"},
{"a missing file", "/dev/null", "/dev/null"},
// git terminates the field with a tab when the path has a space in it.
{"path with a space", "b/with space.go\t", "with space.go"},
// With core.quotePath enabled (the default) every non-ASCII byte is
// escaped, and the field is quoted as a whole, prefix included.
{"non-ASCII path", `"b/caf\303\251.go"`, "café.go"},
{"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"},
{"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`},
{"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`},
{"path with a tab", `"b/tab\there.go"`, "tab\there.go"},
{"undecodable", `"b/unterminated`, ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field))
})
}
}
func TestParseDiffLineFromBufferQuotedPath(t *testing.T) {
// A rename of a file whose name needs quoting, with a content change: the
// path is quoted on the "diff --git" line and on both of the +++/--- lines.
renamed := []string{
`diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`,
"similarity index 62%",
`rename from "caf\303\251 old.go"`,
`rename to "caf\303\251 new.go"`,
"index 1111111..2222222 100644",
"--- \"a/caf\\303\\251 old.go\"\t",
"+++ \"b/caf\\303\\251 new.go\"\t",
"@@ -1,2 +1,2 @@",
" apple",
"-grape",
"+kiwi",
}
result, ok := parseDiffLineFromBuffer(renamed, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
// The same rename without a content change has no +++/--- lines, so the path
// comes from the "diff --git" line, where both paths are quoted.
result, ok = parseDiffLineFromBuffer(renamed[:4], 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
}
func TestParseAllDiffLinesFromBuffer(t *testing.T) {
// Some decoration above the diff, which belongs to no file section: a commit
// message and a diffstat, as `git show` renders them.
bufferLines := append(
[]string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""},
strings.Split(twoFileDiff, "\n")...,
)
all := parseAllDiffLinesFromBuffer(bufferLines)
// The batch parse resolves each file section once, and has to agree with
// resolving the lines one at a time.
assert.Len(t, all, len(bufferLines))
for i := range bufferLines {
parsed, ok := parseDiffLineFromBuffer(bufferLines, i)
assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i])
}
// The lines above the first file section are left unresolved.
for i := range 6 {
assert.False(t, all[i].ok)
}
assert.True(t, all[6].ok)
}
func TestParseDiffLineMetadata(t *testing.T) {
scenarios := []struct {
name string
payload string
expected parsedDiffLine
expectOk bool
}{
{"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
{"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "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 one.
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "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{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
// The path is the last field, so a ';' within it survives.
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
// A renderer may state the path absolutely; the parser keeps it verbatim
// and leaves resolving it to the caller.
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
// A file header has no line number; 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{Path: "foo.txt", Type: types.DiffLineFileHeader}, true},
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
// A file header's line number is always empty, but a renderer that fills
// it in anyway is taken at its word rather than rejected.
{"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "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-file line; on any other kind the
// record is malformed, and rejecting it falls the row back to the diff
// text rather than acting on a line number we don't have.
{"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)
}
})
}
}
func TestRenderingStatesDiffLines(t *testing.T) {
row := func(text string, records ...string) gocui.DiffLineContent {
return gocui.DiffLineContent{Text: text, Metadata: records}
}
scenarios := []struct {
name string
contents []gocui.DiffLineContent
expected bool
}{
{
name: "a rendering without records is read as a diff",
contents: []gocui.DiffLineContent{row("diff --git a/foo.txt b/foo.txt"), row("+one")},
expected: false,
},
{
name: "a record on any row makes the records the source",
contents: []gocui.DiffLineContent{row("foo.txt"), row("one", "1;c;1;;foo.txt"), row("")},
expected: true,
},
{
// A renderer announces the protocol with a record that names no line; a
// rendering with nothing but that one says nothing about its rows.
name: "the version-only handshake record doesn't count",
contents: []gocui.DiffLineContent{row("foo.txt", "1"), row("one")},
expected: false,
},
{
name: "records of a version we don't understand don't count",
contents: []gocui.DiffLineContent{row("one", "2;c;1;;foo.txt")},
expected: false,
},
{
name: "an empty rendering states nothing",
contents: nil,
expected: false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, renderingStatesDiffLines(s.contents))
})
}
}
func TestParseDiffLineIdentities(t *testing.T) {
row := func(text string, records ...string) gocui.DiffLineContent {
return gocui.DiffLineContent{Text: text, Metadata: records}
}
t.Run("a rendering with records is read by them alone", func(t *testing.T) {
// A renderer's picture of a commit that adds a test whose input is a diff. The
// test's "diff --git" line is an added line of the test's file and is shown on
// a row of its own, and the rows the renderer puts between hunks carry no
// record. Parsed as a diff, that row would open a section of a file the diff
// doesn't have and claim every untagged row below it.
contents := []gocui.DiffLineContent{
row("src/parser.rs", "1;f;;;src/parser.rs"),
row(`let input = "\`, "1;c;10;;src/parser.rs"),
row("diff --git a/img.png b/img.png", "1;a;11;;src/parser.rs"),
row("Binary files a/img.png and b/img.png differ", "1;a;12;;src/parser.rs"),
row(""),
row("fn later() {}", "1;c;40;;src/parser.rs"),
}
assert.Equal(t, [][]parsedDiffLine{
{{Path: "src/parser.rs", Type: types.DiffLineFileHeader}},
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 10}},
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 11}},
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 12}},
nil,
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 40}},
}, parseDiffLineIdentities(contents))
})
t.Run("a row with two records shows both of their lines", func(t *testing.T) {
contents := []gocui.DiffLineContent{
row("two │ TWO", "1;d;2;2;file1", "1;a;2;;file1"),
}
assert.Equal(t, [][]parsedDiffLine{
{
{Path: "file1", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2},
{Path: "file1", Type: types.DiffLineAdded, NewLine: 2},
},
}, parseDiffLineIdentities(contents))
})
t.Run("a rendering without records is parsed as a diff", func(t *testing.T) {
bufferLines := strings.Split(twoFileDiff, "\n")
contents := make([]gocui.DiffLineContent, len(bufferLines))
for i, line := range bufferLines {
contents[i] = row(line)
}
identities := parseDiffLineIdentities(contents)
for i, parsed := range parseAllDiffLinesFromBuffer(bufferLines) {
assert.True(t, parsed.ok, "line %d", i)
assert.Equal(t, []parsedDiffLine{parsed.parsed}, identities[i], "line %d", i)
}
})
}
@@ -0,0 +1,164 @@
package helpers
import (
"path/filepath"
"slices"
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// Reading a rendering back to the diff it came from. What a diff view shows is a diff
// renderer's picture of a diff, and a picture is not what you want on your clipboard,
// or in a patch — so the lines of interest are located by identity in the diff itself,
// which the panel that rendered it hands out (types.FocusedMainViewDiffSource).
// PlainDiffOfSelection returns the text of the rows a selection covers in view, the
// inclusive buffer-line range [first, last] (see DiffLinesInBufferRange), in two parts.
//
// aboveDiff is what the selection covers above the diff, as it reads on screen; see
// textAboveDiff. fromDiff is the diff behind the rest: per file the selection touches,
// the run of diff lines from the first of its selected lines to the last, with the
// files in the order the selection meets them.
//
// A run, rather than the matched lines alone, so that what comes out reads as a diff:
// the lines between two selected ones come along even when the rendering didn't show
// them (difftastic leaves out whitespace-only changes) or showed them in another order
// (a side-by-side rendering groups the deletions of a hunk before its additions).
//
// Headers are selected lines like any other. A hunk header names the first line of its
// hunk, in the rendering as in the diff, so it is looked for the way a line of the file
// is; a file header names no line at all and a rendering may spread it over as many
// rows as it likes, so a selection touching one of them takes the whole header.
//
// plainDiff fetches the diff of the given repo-relative files, and is asked only for
// the files the selection touches, so that copying three lines of a commit's diff
// doesn't fetch the whole of it. fromDiff is "" when no selected row could be found in
// the diff, e.g. because the selection covers nothing but a renderer's decoration.
func (self *DiffLineHelper) PlainDiffOfSelection(
view *gocui.View, first int, last int, plainDiff func(paths []string) string,
) (aboveDiff string, fromDiff string) {
worktreePath := self.c.Git().RepoPaths.WorktreePath()
aboveDiff = self.textAboveDiff(view, first, last)
// The files in the order they are shown, and per file what the selection covers of
// its diff.
paths := []string{}
selection := map[string]*selectedDiffLines{}
for _, info := range self.DiffLinesInBufferRange(view, first, last) {
// A row that is neither a line of the file nor a header — the "\ No newline at
// end of file" marker — names nothing to look for. It comes along anyway when it
// falls within a run.
if info.Type == types.DiffLineOther {
continue
}
selected, ok := selection[info.Path]
if !ok {
paths = append(paths, info.Path)
selected = &selectedDiffLines{lines: map[patchLine]bool{}}
selection[info.Path] = selected
}
if info.Type == types.DiffLineFileHeader {
selected.header = true
} else {
selected.lines[patchLineOf(info)] = true
}
}
relPaths := repoRelativePaths(worktreePath, paths)
if len(relPaths) == 0 {
return aboveDiff, ""
}
diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n")
runs := map[string][2]int{}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) {
if !parsed.ok {
continue
}
info := diffLineInfoIn(worktreePath, parsed.parsed)
selected := selection[info.Path]
if selected == nil || !selected.covers(info) {
continue
}
if run, ok := runs[info.Path]; ok {
runs[info.Path] = [2]int{run[0], i}
} else {
runs[info.Path] = [2]int{i, i}
}
}
text := strings.Builder{}
for _, path := range paths {
run, ok := runs[path]
if !ok {
continue
}
for _, line := range diffLines[run[0] : run[1]+1] {
text.WriteString(line)
text.WriteString("\n")
}
}
return aboveDiff, text.String()
}
// textAboveDiff returns the text of the selected rows that sit above the diff, as they
// read on screen. What a diff view shows before its first file — a commit's message and
// git's summary of it — is part of no file's diff, so there is nothing to look those
// rows up in and the rendering is all we have of them.
//
// It is "" for a view that shows no file at all: with no diff on screen there is
// nothing for the text to be above, and a rendering we couldn't read as a diff is
// exactly what we don't want on the clipboard.
func (self *DiffLineHelper) textAboveDiff(view *gocui.View, first int, last int) string {
startOfDiff := slices.IndexFunc(self.filePaths(view), func(path string) bool { return path != "" })
if startOfDiff == -1 {
return ""
}
last = min(last, startOfDiff-1)
if last < first {
return ""
}
return strings.Join(view.BufferLines()[first:last+1], "\n") + "\n"
}
// selectedDiffLines is what a selection covers of one file's diff.
type selectedDiffLines struct {
// The lines of the file to look for, hunk headers among them, by the identity that
// names them in any rendering of the diff.
lines map[patchLine]bool
// Whether the file's header is covered, in whole or in part.
header bool
}
// covers reports whether the given line of a file's diff is one the selection holds.
//
// A file header is answered for by kind rather than looked for: the two ways a row's
// identity is recovered disagree about what to call it — a parse of the diff says the
// file's first line, a renderer's record says the file has no line — and a rendering
// may show the header as one row or as ten. So a selection that touches it holds every
// line of it.
func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
if info.Type == types.DiffLineFileHeader {
return self.header
}
return self.lines[patchLineOf(info)]
}
// repoRelativePaths turns the absolute paths a diff line's identity carries into the
// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff
// renderer states the path however it likes, and one we can't place is one we can't
// ask git about.
func repoRelativePaths(worktreePath string, paths []string) []string {
relPaths := make([]string, 0, len(paths))
for _, path := range paths {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
relPaths = append(relPaths, filepath.ToSlash(relPath))
}
return relPaths
}
@@ -0,0 +1,380 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// The questions a diff view can be asked about what it is showing — where the change
// lines are, which block or file a row belongs to — answered in the view-line terms a
// cursor and a click speak, except for what a selection covers (see
// DiffLinesInBufferRange). They are all built on the identities recovered in
// diff_line_helper.go, which is where the answering stops and the recovering starts.
// DiffLinesInBufferRange returns the identity of every diff line shown by the rows in
// the inclusive buffer-line range [first, last] of view's rendered diff, in display
// order. Rows whose identity can't be recovered are left out.
//
// The range is what a selection covers (see View.SelectedBufferLineRange). It is given
// in buffer lines because a buffer line stays on its line of the content however the
// view wraps it, so the selection can be read when a key is pressed and acted on after
// the view has changed.
//
// A row can show more than one diff line — a side-by-side rendering puts a deletion
// beside the addition replacing it — and all of them are reported: what the user
// pointed at is the row, so everything on it is selected.
func (self *DiffLineHelper) DiffLinesInBufferRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
infos := []types.DiffLineInfo{}
for bufferLine := first; bufferLine <= min(last, len(identities)-1); bufferLine++ {
infos = append(infos, identities[bufferLine]...)
}
return infos
}
// ChangeLinesInBufferRange returns the change lines — the additions and deletions —
// among the diff lines shown by the rows in the inclusive buffer-line range. Those are
// the lines a patch is built from: a patch carries whatever context it needs around
// them by itself, so a selection contributes only its changes.
func (self *DiffLineHelper) ChangeLinesInBufferRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
return lo.Filter(self.DiffLinesInBufferRange(view, first, last),
func(info types.DiffLineInfo, _ int) bool { return info.IsChange() })
}
// changeLines resolves view's rendered diff to one flag per buffer line: whether
// that row is a change line (an addition or a deletion), as opposed to context, a
// header, or a row whose identity couldn't be recovered. Those are the rows a
// selection is anchored on and navigation moves between.
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
resolved := self.resolveDiffLines(view.DiffLineContents())
isChange := make([]bool, len(resolved))
for i, r := range resolved {
isChange[i] = r.ok && r.info.IsChange()
}
return isChange
}
// FirstChangeLineInView returns the view line of the first change line on screen. It
// is where the selection goes when the main view is focused by keyboard: focusing a
// diff you are reading points at something in it without moving it, so the search
// stops at the bottom of the viewport rather than going after a change further down.
// ok is false when the viewport holds no change line — scrolled into a long stretch
// of context, or past the last change.
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] {
return view.ViewLineForBufferLine(i)
}
}
return 0, false
}
// FirstChangeBlockInView returns the view line of the first change block on screen:
// the first one that *begins* in the viewport, and failing that the one that reaches
// into the viewport from above, whose start is off screen. Hunk mode wants that order
// for the block it offers up on focus: preferably a block whose beginning the user can
// see, rather than the tail of one they have scrolled past the start of. The block
// bleeding in from above is kept as the answer for a change too long to fit on screen,
// where there is no other. ok is false when the viewport shows no change line.
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] && (i == 0 || !isChange[i-1]) {
return view.ViewLineForBufferLine(i)
}
}
// A block covering the top line is one that began above it: nothing else can put a
// change there once no block starts on screen.
if top < len(isChange) && isChange[top] {
return view.ViewLineForBufferLine(top)
}
return 0, false
}
// visibleBufferLines returns the first and last line of view's content that the
// viewport shows any part of, for the queries that only care about what the user can
// see. The last line is the one at the bottom edge, or the content's last when the
// content ends above it. ok is false for a view showing no content at all.
func visibleBufferLines(view *gocui.View) (int, int, bool) {
top, ok := view.BufferLineForViewLine(view.OriginY())
if !ok {
return 0, 0, false
}
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
bottom, ok := view.BufferLineForViewLine(lastVisible)
if !ok {
return top, top, true
}
return top, bottom, true
}
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
// all, i.e. whether there is anything to select. It is false over a non-diff
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
// which are the cases where the focused main view shows no selection.
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
return lo.Contains(self.changeLines(view), true)
}
// IsChangeLine reports whether the given view line of view's rendered diff is a
// change line rather than context, a header, or an unresolvable row — i.e. whether
// pointing at it points at something a patch could be built from.
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
return ok && info.IsChange()
}
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
// is shown as one solid block of changes — every row of its diff a change of the same
// kind, no context — which is what a newly added or deleted file looks like. That is
// the case where widening the selection to the change block would select the file
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
//
// It says false while the diff is still being read in, since the rows that would
// answer otherwise — a context line, a change of the other kind — may not have
// arrived yet. That errs towards hunk mode, which is what the user asked for.
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
return false
}
anchor, ok := view.BufferLineForViewLine(changeViewLine)
if !ok {
return false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) || !resolved[anchor].ok {
return false
}
// The question is per file: a commit's diff may hold a newly added file next to an
// edited one.
path := resolved[anchor].info.Path
kind := resolved[anchor].info.Type
for _, row := range resolved {
if !row.ok || row.info.Path != path {
continue
}
if row.info.Type == types.DiffLineContext {
return false
}
if row.info.IsChange() && row.info.Type != kind {
return false
}
}
return true
}
// ChangeBlockBounds returns the inclusive view-line range of the change block to
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
// single git @@ hunk may hold several. When the anchor is context, the block used is
// the first at or below it, or — with nothing below, the cursor sitting past the last
// change — the nearest above, so that hunk mode always has a block to select. ok is
// false only when the diff holds no change line at all.
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, 0, false
}
isChange := self.changeLines(view)
start := anchor
for start < len(isChange) && !isChange[start] {
start++
}
if start >= len(isChange) {
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
}
if start < 0 {
return 0, 0, false
}
}
end := start
for start > 0 && isChange[start-1] {
start--
}
for end < len(isChange)-1 && isChange[end+1] {
end++
}
startView, startOk := view.ViewLineForBufferLine(start)
// The block's last line goes to its last view line, so that a line the view
// wrapped is highlighted to its end rather than only where it begins.
endView, endOk := view.LastViewLineForBufferLine(end)
if !startOk || !endOk {
return 0, 0, false
}
return startView, endView, true
}
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
// of the next block, forward=false the start of the previous one — from mid-block that
// means the previous block, rather than the one we are in. ok is false when there's no
// further block, so the caller leaves the view where it is.
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
target, ok := changeBlockStart(self.changeLines(view), 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) rendered diff, starting from anchorViewLine: the first
// located row of the neighbouring file, found where the rows' file changes. ok is
// false at the first or last file.
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
target, ok := fileStart(self.filePaths(view), anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
// empty for a row whose identity couldn't be recovered.
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
resolved := self.resolveDiffLines(view.DiffLineContents())
paths := make([]string, len(resolved))
for i, row := range resolved {
if row.ok {
paths[i] = row.info.Path
}
}
return paths
}
// 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 look for where the path changes, skipping
// rows that carry none: those are the blank separator rows between files, or the
// header rows of a diff renderer that doesn't state which file its headers belong to.
// So the landing row is the file's header wherever the source says so — a parseable
// buffer, or a renderer that tags its headers — and the file's first content line
// otherwise, which is an accepted degradation.
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 ones) to the previous
// file's last located row, then back over that whole file, landing on its first.
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 is nothing below. Scanning down
// first matters because the anchor is often a file-header row that carries no path of
// its own, whose nearest tagged row above is the *previous* file's content; taking
// that would make next-file navigation jump back into the file just left, so a second
// press wouldn't advance. ok is false when no row carries a path.
func anchorFilePath(paths []string, from int) (string, bool) {
if from < 0 {
return "", false
}
for i := from; i < len(paths); i++ {
if paths[i] != "" {
return paths[i], true
}
}
for i := min(from, len(paths)) - 1; i >= 0; i-- {
if paths[i] != "" {
return paths[i], true
}
}
return "", false
}
// 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.
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
if from < 0 || from >= len(isChange) {
return 0, false
}
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 0, false
}
return i, true
}
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
}
@@ -0,0 +1,104 @@
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},
{"an anchor past the end finds nothing", 9, true, 0, false},
{"a negative anchor finds nothing", -1, true, 0, false},
}
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, headers included,
// as the buffer parser reports it.
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
// The same diff as a renderer that doesn't say which file its headers belong to
// emits it: only content lines carry the path, so navigation can land no higher
// than each file's first content line.
contentOnly := []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's file has to
// be found by scanning down (b) rather than up (a) — otherwise next-file would
// jump back into b and a second press couldn't advance.
contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
// A renderer that does tag its header rows: the file header and the hunk-header box
// carry the file's path, but the blank separator rows around them carry nothing.
// Navigation must land on the header's first row, not the blank line above it.
// 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content
// 8 blank 9-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
}{
{"forward lands on the next file's header", parseable, 1, true, 4, true},
{"forward from the last file finds nothing", parseable, 5, true, 0, false},
{"backward lands on the previous file's header", parseable, 5, false, 0, true},
{"backward from the first file finds nothing", parseable, 1, false, 0, false},
{"forward lands on the next file's first content line", contentOnly, 2, true, 6, true},
{"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true},
{"forward from an untagged header advances past it", contentOnly, 0, true, 6, true},
{"a second forward press advances again", contentOnlyThree, 4, true, 10, true},
{"forward lands on a tagged file header", headerTagged, 7, true, 9, true},
{"backward lands on a tagged file header", headerTagged, 14, false, 1, true},
{"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false},
}
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,112 @@
package helpers
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
)
// Falling back to git's own diff when the configured one can't be acted on.
//
// A diff renderer is free to lay a diff out however it likes, and once it has, we can
// only tell which line of which file a row shows if the renderer says so. A renderer
// that doesn't produces a diff that can be read but not staged, edited or copied from,
// so when the user focuses the main view to act on it, we show git's own diff instead.
// Browsing keeps the renderer's version; only acting on it needs one we can follow.
// MainViewDiffMode says how a side panel should produce the diff it renders into the
// main view: as the user configured it, or as git's own — while the main view holds
// focus and what the renderer would produce couldn't be acted on.
//
// Every panel that renders a diff into the main view asks, so that a re-render while
// focused — after staging a hunk, say — stays with git's own diff rather than flipping
// back to the renderer's.
func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
if self.mainViewIsFocused() && self.diffNeedsMetadata() && !self.diffRendererEmitsMetadata() {
return git_commands.DiffModeRaw
}
return git_commands.DiffModeRendered
}
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
// diff again — which, the main view now holding focus, is git's own diff rather than
// the renderer's — and calls place once that is on screen.
//
// The whole diff is read before it is shown, rather than the first screenful: place
// looks at what is there to decide where to put the selection, and a change line
// further down would otherwise be missed.
//
// It is the same diff of the same files, so the view keeps the scroll position it has
// rather than starting from the top: git lays the changes out its own way and the line
// the user was on is somewhere else now, but the offset still puts them among the same
// part of the file — and the selection is then established from what that leaves on
// screen.
func (self *DiffLineHelper) RenderFocusedMainViewAgain(view *gocui.View, sidePanel types.Context, place func()) {
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
return
}
manager.SetKeepScrollPositionForNextTask()
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) {
swapIn()
place()
},
})
sidePanel.HandleRenderToMain()
}
func (self *DiffLineHelper) mainViewIsFocused() bool {
current := self.c.Context().CurrentStatic().GetKey()
return current == self.c.Contexts().Normal.GetKey() ||
current == self.c.Contexts().NormalSecondary.GetKey()
}
// diffNeedsMetadata reports whether the diff we would show is one whose rows can only
// be placed in the file by the records the renderer states. Any custom renderer may
// restructure the diff; so may git itself, once the renderer's arguments ask for a word
// diff, whose markup is inline. Plain git output describes itself, and needs no records.
func (self *DiffLineHelper) diffNeedsMetadata() bool {
manager := self.c.State().GetDiffRendererConfigManager()
if manager.GetDiffRendererType() != config.DiffRendererType_RawGit {
return true
}
return len(manager.GetRawGitArgs()) > 0
}
// diffRendererEmitsMetadata is the probed verdict about the current diff renderer, asked
// once and remembered until the renderer changes — the user cycling to another one, or a
// changed config being reloaded.
func (self *DiffLineHelper) diffRendererEmitsMetadata() bool {
signature := self.diffRendererSignature()
if self.rendererEmitsMetadata == nil || signature != self.rendererSignature {
verdict := self.c.Git().Diff.ProbeDiffRendererEmitsMetadata()
self.rendererEmitsMetadata = &verdict
self.rendererSignature = signature
}
return *self.rendererEmitsMetadata
}
// diffRendererSignature identifies the current diff renderer, so that the remembered
// verdict is dropped when it stops describing the renderer we have. The values a command
// is resolved with are no part of its identity, so fixed ones are used.
func (self *DiffLineHelper) diffRendererSignature() string {
manager := self.c.State().GetDiffRendererConfigManager()
index, _ := manager.CurrentDiffRendererIndex()
values := config.DiffRendererValues{}
externalDiff, _ := manager.GetExternalDiffCommand(values)
stdinFilter, _ := manager.GetStdinFilterCommand(values)
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
index,
externalDiff,
stdinFilter,
strings.Join(manager.GetRawGitArgs(), "\x00"))
}
@@ -0,0 +1,545 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/samber/lo"
)
// Keeping a diff view where it is when the same diff is rendered again differently.
// The line the user is on is remembered by identity (diff_line_helper.go), because a
// new rendering puts it on a different line of the view — and may not have it at all,
// which is what the fallbacks below are for.
// diffLineAnchor is a line for a restore to land on: the identity to find it by in
// the new rendering, and the screen row it was on, so that it can be put back there.
type diffLineAnchor struct {
identity types.DiffLineInfo
row int
}
// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back
// there as it next re-renders, instead of leaving the user at the top of a new
// rendering of the diff they were already reading. Call it on the view about to be
// re-rendered, right before triggering the re-render — on both panes of the main
// window where both are being rendered again, since either of them may hold the diff
// being read; a pane that isn't showing is left alone.
//
// The line to keep is the end of the selection that is on screen, and the middle
// visible line when there is no selection or the whole of it has been scrolled out of
// sight — what the user is looking at, rather than the view's top edge or a selection
// they have long since left behind. It may not survive the re-render: a context line
// goes when the context size shrinks, and a whole hunk or file goes when whitespace
// stops counting. So the lines around it come along as fallbacks and the view lands on
// the nearest one that is still there, put back on the screen row it was on. With none
// of them left — and with a renderer that says nothing about its rows there is nothing
// to look for in the first place — the view keeps the scroll offset it had, which is
// still nearer to what was being read than the top of the diff.
//
// An off-screen selection is still put back on the diff line it was on, wherever the
// new rendering has that; it is only the view that stays where it is.
//
// A range or hunk selection has a second end, which is remembered the same way, so
// that it still covers the same lines of the diff afterwards.
func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// A view that isn't the one its window is currently showing — the merge-conflicts
// view takes the main window over — isn't the one about to be re-rendered, so a
// restore installed on it would sit there and claim a later render instead.
if !view.Visible {
return
}
// The re-render is produced by a different command from the one behind what is on
// screen — another context size, another renderer — so without being told otherwise
// it would be taken for content the user has never seen and shown from the top.
// Whether or not a line of the old rendering can be found in the new one, the offset
// into it is nearer to where they were reading than the top is.
if manager := self.c.GetViewBufferManagerForView(view); manager != nil {
manager.SetKeepScrollPositionForNextTask()
}
showSelection := view.Highlight
anchorViewLine := view.MiddleVisibleLineIdx()
farEnd, hasFarEnd := types.DiffLineInfo{}, false
// A cursor that has been scrolled away from is put back by its own lines rather
// than by the anchor's, so that it comes out on the same line of the diff without
// the view having to go there.
var cursorCandidates []diffLineAnchor
if showSelection {
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
if end, ok := visibleSelectionEnd(view); ok {
anchorViewLine = end
}
if anchorViewLine != view.SelectedLineIdx() {
cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx())
}
}
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine),
func(anchor diffLineAnchor, viewLine int) {
// Put the line back on the screen row it was on, clamped into the view for
// the fallback lines, which can come from off screen.
row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1))
view.SetOrigin(0, max(0, viewLine-row))
if showSelection {
// Put the far end back before the cursor, so that the selection covers
// the same lines again; a selection whose far end didn't survive the
// re-render is left as the single line we landed on. The origin is
// already where it should be, so moving the cursor mustn't scroll.
view.CancelRangeSelect()
cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine)
if hasFarEnd {
if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok {
cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine)
view.SetRangeSelectStart(farEndViewLine)
}
}
view.FocusPoint(0, cursorViewLine, false)
}
})
}
// coverWholeLines moves the two ends of a restored selection out to the edges of the
// diff lines they are on, so that the selection covers those lines whole. Both ends
// arrive on the first view line of their diff line, which is where looking one up by
// identity lands, and the view draws a line it wraps as several — of which a
// selection of that line means all.
func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) {
if cursorViewLine <= farEndViewLine {
return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine)
}
return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine
}
// lastViewLineOfSameDiffLine returns the last view line showing the same line of the
// diff as the given one, which is that line itself unless the view wrapped it.
func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return viewLine
}
if last, ok := view.LastViewLineForBufferLine(bufferLine); ok {
return last
}
return viewLine
}
// visibleSelectionEnd returns the end of the selection to keep in place across a
// re-render: the selected line when it is on screen, and the range's other end when
// that is and the selected line isn't — a range can be long enough for the user to be
// looking at one end of it with the other far away. ok is false when the whole
// selection is off screen, and there is nothing of it to keep in place.
func visibleSelectionEnd(view *gocui.View) (int, bool) {
if view.IsLineVisible(view.SelectedLineIdx()) {
return view.SelectedLineIdx(), true
}
if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) {
return farEnd, true
}
return 0, false
}
// selectionLine returns the line to put the cursor on once a re-render is on screen:
// the line the position anchor landed on, which is the selected one whenever it was
// on screen, and otherwise the nearest surviving line to where the selection was —
// found among its own candidates, since the anchor's are a search of the diff from
// somewhere else entirely.
func (self *DiffLineHelper) selectionLine(
view *gocui.View, candidates []diffLineAnchor, anchorViewLine int,
) int {
if len(candidates) == 0 {
return anchorViewLine
}
_, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates)
if bufferLine == -1 {
return anchorViewLine
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
return viewLine
}
return anchorViewLine
}
// selectionFarEndIdentity returns the identity of the end of a range or hunk
// selection the cursor isn't on, so that a re-render can put it back. ok is false for
// a selection that is only a cursor, where restoring that is the whole job, and for
// an end that resolves to no diff line.
//
// An end covers the whole of its row, so where the row shows more than one diff line
// — a rendering that puts a modification's two halves side by side, or a word diff
// that puts both on the one line it changed — the end takes the outermost of them:
// the last for the range's lower end and the first for its upper one. Otherwise a
// rendering that splits them apart again would get back only the half the row led
// with, and half a change selected where a whole one was.
func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
farEnd, isLowerEnd, ok := selectionFarEndViewLine(view)
if !ok {
return types.DiffLineInfo{}, false
}
identities, ok := self.diffLineIdentitiesAt(view, farEnd)
if !ok {
return types.DiffLineInfo{}, false
}
if isLowerEnd {
return identities[len(identities)-1], true
}
return identities[0], true
}
// selectionFarEndViewLine returns the view line of the end of a range or hunk
// selection the cursor isn't on, and whether that is the lower of the two ends. ok
// is false when there is no range at all, only a cursor.
//
// A range whose two ends are on the same view line still has one, and is not the
// same thing as a cursor sitting there: it covers everything that row shows, which
// may be two lines of the diff at once.
func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) {
if !view.HasRangeSelect() {
return 0, false, false
}
first, last := view.SelectedLineRange()
if view.SelectedLineIdx() == first {
return last, true, true
}
return first, false, true
}
// findDiffLine returns the view line showing the given diff line in what view is
// displaying now, for placing a remembered line once the re-render is on screen.
func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)]
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(bufferLine)
}
// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the
// first of the given candidate lines the new rendering still has, calling place with
// that candidate and the view line it ended up on. The candidates are in priority
// order (see nearbyDiffLines); if the rendering has none of them, place isn't called
// and the view re-renders as it otherwise would.
//
// The nearest candidate is looked for as the content loads, so that the re-render can
// be revealed at the right position as soon as that line and a screenful below it
// have arrived. Only the nearest one, because the candidates aren't in load order: a
// farther one can load first, and landing on it while a nearer one is still on its
// way would be settling for worse. The rest are considered together once the whole
// rendering is there.
func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int),
) {
if len(candidates) == 0 {
return
}
// The search of the loading content runs on the task's own goroutine, where the
// repo we are in may not be read — a repo switch replaces it — so take it here, on
// the UI thread, for the search to work from.
worktreePath := self.c.Git().RepoPaths.WorktreePath()
// Which candidate the search settled on, for place to put back where it was.
found := diffLineAnchor{}
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
found = candidates[0]
return offset + i, true
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
anchor, bufferLine := self.nearestSurvivingCandidate(contents, candidates)
if bufferLine == -1 {
return 0, false
}
found = anchor
return bufferLine, true
},
func(viewLine int) { place(found, viewLine) },
nil,
)
}
// ChangeLineOrdinal returns how many change lines of view's rendered diff come before
// the given buffer line — that line's place in the sequence of changes.
//
// It is how a place in a diff is remembered across acting on it: an action consumes
// the lines it acted on, so the identity of the line the user was on is gone, but the
// place it left behind is the same one that identity used to have.
func (self *DiffLineHelper) ChangeLineOrdinal(view *gocui.View, bufferLine int) int {
ordinal := 0
for i, row := range self.resolveDiffLines(view.DiffLineContents()) {
if i >= bufferLine {
break
}
if row.ok && row.info.IsChange() {
ordinal++
}
}
return ordinal
}
// RevealChangeLineAtOrdinal arranges for view's next re-render to be shown with the
// change line at the given ordinal placed by place — the diff having changed under the
// user, this is where what they were doing carries on. When the new diff has fewer
// changes than that, because the ones acted on were its last, it lands on the last
// change left.
//
// done is called once the selection is where it belongs, or once it turns out that no
// render is coming to put it there, for a caller that must not let the user act again
// in between.
func (self *DiffLineHelper) RevealChangeLineAtOrdinal(
view *gocui.View, ordinal int, place func(viewLine int), done func(),
) {
// How many change lines the incremental search has passed, so that it can carry on
// counting where it left off.
seen := 0
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if info, ok := self.diffLineInfoFromRecords(row.Metadata); ok && info.IsChange() {
if seen == ordinal {
return offset + i, true
}
seen++
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
last, count := -1, 0
for i, row := range self.resolveDiffLines(contents) {
if !row.ok || !row.info.IsChange() {
continue
}
if count == ordinal {
return i, true
}
count++
last = i
}
return last, last != -1
},
place,
done,
)
}
// installDiffLineRestore is what the restores are built on: it arranges for view's
// next re-render to be revealed with the row a search finds in it placed by place,
// instead of from the top.
//
// The search comes in two halves, because the content arrives a line at a time.
// findEarly is given the rows that have loaded since it last looked, so that the
// re-render can be revealed as soon as the row is there rather than waiting for the
// rest of a long diff; it can only go by what the renderer states about a row, a
// partly-loaded diff being unparseable. findComplete is given the whole rendering at
// the swap, for a target the incremental search couldn't settle on. Either returns the
// buffer line it found, and place is not called at all when neither does.
func (self *DiffLineHelper) installDiffLineRestore(
view *gocui.View,
findEarly func(rows []gocui.DiffLineContent, offset int) (int, bool),
findComplete func(contents []gocui.DiffLineContent) (int, bool),
place func(viewLine int),
done func(),
) {
// Get-or-create, because the pane may not have rendered anything yet: a file whose
// diff has only just become split has a second pane whose first render is the one
// this restore is for.
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
if done != nil {
done()
}
return
}
// The readiness check below runs on the task's own goroutine, which may not read
// the view's dimensions, so take them here, on the UI thread.
viewHeight := view.InnerHeight()
// What the search of the loading content has found, and how far it has looked, so
// that each line is looked at once.
foundLine := -1
scanned := 0
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool {
if foundLine == -1 {
rows := view.OffscreenDiffLineContentsFrom(scanned)
if bufferLine, ok := findEarly(rows, scanned); ok {
foundLine = bufferLine
}
scanned += len(rows)
if foundLine == -1 {
return false
}
}
// Wait for a screenful below the line as well, so that the re-render isn't
// revealed with it stranded at the bottom of a half-filled view.
return view.OffscreenLineCount() >= foundLine+viewHeight
},
Apply: func(swapIn func()) {
bufferLine := foundLine
if bufferLine == -1 {
if line, ok := findComplete(view.OffscreenDiffLineContents()); ok {
bufferLine = line
}
}
swapIn()
if bufferLine == -1 {
return
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
place(viewLine)
}
},
Done: done,
})
}
// nearbyDiffLines collects the lines of view's rendered diff as candidates for a
// restore to land on, ordered by proximity to the anchor line — the anchor itself
// first, then outward, preferring at-or-below on ties — each tagged with the screen
// row it is on. A restore lands on the first of them its re-render still has, so this
// order makes it land as near as possible to where the user was.
//
// The walk covers the whole diff rather than stopping at the change lines on either
// side of the anchor, which a context-size change always keeps: ignoring whitespace
// keeps nothing in particular, and can take a hunk or a whole file out of the diff,
// leaving the nearest surviving line in a neighbouring file.
func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return nil
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) {
return nil
}
rows := screenRows(view, len(resolved))
candidates := make([]diffLineAnchor, 0, len(resolved))
collect := func(bufferLine int) {
if line := resolved[bufferLine]; line.ok {
candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]})
}
}
collect(anchor)
for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 {
if below < len(resolved) {
collect(below)
}
if above >= 0 {
collect(above)
}
}
return candidates
}
// screenRows maps each line of view's content to the screen row it is drawn on. The
// lines above the visible ones get -1 and those below them the view's height, so that
// putting one of them back where it was lands it at the top or bottom edge.
func screenRows(view *gocui.View, bufferLineCount int) []int {
height := view.InnerHeight()
originY := view.OriginY()
rows := make([]int, bufferLineCount)
for i := range rows {
rows[i] = -1
}
lastVisible := -1
for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ {
bufferLine, ok := view.BufferLineForViewLine(y)
if !ok || bufferLine >= bufferLineCount {
continue
}
if rows[bufferLine] == -1 {
rows[bufferLine] = y - originY
}
lastVisible = bufferLine
}
for i := lastVisible + 1; i < bufferLineCount; i++ {
rows[i] = height
}
return rows
}
// nearestSurvivingCandidate returns the first of the candidates that the given
// rendering still shows, and the line of it that does. The rendering is indexed
// first, rather than searched once per candidate: the candidate list is as long as
// the diff, and so is the rendering.
func (self *DiffLineHelper) nearestSurvivingCandidate(
contents []gocui.DiffLineContent, candidates []diffLineAnchor,
) (diffLineAnchor, int) {
rows := self.patchLineRows(contents)
for _, candidate := range candidates {
if line, ok := rows[patchLineOf(candidate.identity)]; ok {
return candidate, line
}
}
return diffLineAnchor{}, -1
}
// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the
// first of its rows that does. A row can show more than one, and each is then a way
// of finding that row again.
func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int {
rows := map[patchLine]int{}
for i, identities := range self.resolveDiffLineIdentities(contents) {
for _, identity := range identities {
if _, seen := rows[patchLineOf(identity)]; !seen {
rows[patchLineOf(identity)] = i
}
}
}
return rows
}
// rowShowsDiffLine reports whether the given row of a rendering shows the given diff
// line — among any others it shows, since a side-by-side rendering puts a deletion
// beside the addition replacing it. It only knows what the renderer states about the
// row, since the alternative, parsing the rendering as a diff, needs whole hunks and
// this is asked of content that is still loading. It takes the repo's worktree path
// rather than reading it, being asked off the UI thread.
func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool {
return lo.SomeBy(row.Metadata, func(record string) bool {
parsed, ok := parseDiffLineMetadata(record)
return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target)
})
}
// patchLine records what stays the same about a diff line when the same diff is
// rendered again differently: which file it belongs to, the line number that
// identifies it on the side it belongs to, and what kind of line it is.
type patchLine struct {
path string
// Every kind of content line collapses into DiffLineContext, since an addition
// and the context line it turns into when whitespace stops counting are the same
// line of the same file. The header rows keep their kind: a file's header and the
// first line of the file it heads are not the same place.
kind types.DiffLineType
// The old file's line number for a deletion, since two consecutive deletions
// share a new-file position and differ only here; the new file's otherwise.
line int
isDeletion bool
}
func patchLineOf(info types.DiffLineInfo) patchLine {
switch info.Type {
case types.DiffLineFileHeader, types.DiffLineHunkHeader:
return patchLine{path: info.Path, kind: info.Type, line: info.NewLine}
case types.DiffLineDeleted:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true}
default:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
}
}
@@ -0,0 +1,133 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// Putting a selection in the focused main view: where it starts out, and how it is
// widened to a whole change block. Both are answered from what the view is showing,
// as recovered by the queries next door.
// EstablishSelection turns on the focused main view's selection once the view has
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
// keyboard focus, which points at no particular line and so starts at the first
// change line on screen.
//
// Focusing never moves the view: you focus the diff you are reading in order to point
// at something in it, so the selection goes where you are looking rather than the
// view going where the selection would like to be. With no change line on screen at
// all — a long stretch of context — it lands on the middle visible line, the likeliest
// one to be the one being read.
//
// With hunk mode configured as the default the selection widens to the whole change
// block: keyboard focus lands on the first block on screen, and a click on a change
// line selects that line's block, ready to act on. A click on context still selects
// just that line — the click points at it precisely, so it stays editable.
func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, clickedViewLine int) {
mainContext.ResetDiffSelectMode()
view := mainContext.GetView()
// The pane may hold nothing to act on: a diff of a binary file or an empty commit,
// or content that is no diff of the panel's at all. Rendering it worked that out,
// so the pane is already showing no selection and there is nowhere to put one.
if !mainContext.HasSelectableContent() {
return
}
if clickedViewLine >= 0 {
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
mainContext.SetDragAnchorViewLine(clickedViewLine)
if self.hunkModeApplies(view, clickedViewLine) && self.IsChangeLine(view, clickedViewLine) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
self.SelectChangeBlock(mainContext, clickedViewLine, false)
return
}
self.ShowSelectionAtLine(view, clickedViewLine, false)
return
}
target, ok := self.changeToSelectOnScreen(view)
if !ok {
self.ShowSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
return
}
if self.hunkModeApplies(view, target) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
self.SelectChangeBlock(mainContext, target, false)
return
}
self.ShowSelectionAtLine(view, target, false)
}
// changeToSelectOnScreen returns the change line keyboard focus establishes the
// selection on. In hunk mode that is the first block that begins on screen, so that
// the block being offered up is one the user can see the extent of, falling back to a
// block that reaches into the view from above — a change longer than the screen, where
// there is nothing else to offer. Line by line it is simply the first change line on
// screen. ok is false when the viewport shows no change at all.
func (self *DiffLineHelper) changeToSelectOnScreen(view *gocui.View) (int, bool) {
if self.c.UserConfig().Gui.UseHunkModeInStagingView {
return self.FirstChangeBlockInView(view)
}
return self.FirstChangeLineInView(view)
}
// hunkModeApplies reports whether an established selection should start out as the
// whole change block around the given change line. That's what the config asks for,
// except over a file shown as one solid block of changes, where it would select the
// whole file — see IsSingleHunkForWholeFile.
func (self *DiffLineHelper) hunkModeApplies(view *gocui.View, changeViewLine int) bool {
return self.c.UserConfig().Gui.UseHunkModeInStagingView &&
!self.IsSingleHunkForWholeFile(view, changeViewLine)
}
// ShowSelectionAtLine moves the focused main view's selection to the given view line,
// clamped to the content. scrollIntoView scrolls the line into view when it's
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
// screen already.
func (self *DiffLineHelper) ShowSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
// A search carries on from where the selection now is, so that stepping to the
// next match goes to the one after it rather than the one after the match the
// user last stepped to.
view.SetNearestSearchPosition()
}
// SelectChangeBlock selects the whole change block around the given change line, for
// hunk mode: the cursor goes to the block's first line and the range anchor to its
// last, so the native range highlight spans the block. With no block to be found —
// a diff with no changes in it — it falls back to a single-line selection.
//
// scrollIntoView brings the block's first line on screen, for the commands that mean
// to go there; a click leaves it false, so that the view doesn't move under the mouse
// when the block the click landed in starts above the viewport.
func (self *DiffLineHelper) SelectChangeBlock(
pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool,
) {
view := pane.GetView()
start, end, ok := self.ChangeBlockBounds(view, changeViewLine)
if !ok {
pane.DiffSelectState().Mode = types.DiffSelectModeLine
view.CancelRangeSelect()
self.ShowSelectionAtLine(view, changeViewLine, scrollIntoView)
return
}
view.SetRangeSelectStart(end)
self.ShowSelectionAtLine(view, start, scrollIntoView)
}
// SelectedHunkBounds returns the change block selected in hunk mode. The range
// anchor stays on the block's far end when a click moves the cursor before its
// handler runs, so it still identifies the selected block.
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
anchor := view.RangeSelectStartY()
if anchor < 0 {
return 0, 0, false
}
return self.ChangeBlockBounds(view, anchor)
}
+10 -2
View File
@@ -46,8 +46,16 @@ func (self *FilesHelper) EditFileAtLineAndWait(filename string, lineNumber int)
// Always suspend, regardless of the value of the suspend config,
// since we want to prevent interacting with the UI until the editor
// returns, even if the editor doesn't use the terminal
return self.callEditor(cmdStr, true)
// returns, even if the editor doesn't use the terminal.
//
// And nothing is refreshed here; that is why this doesn't go through
// callEditor. The editor was handed a patch we wrote for it, so the repo
// hasn't changed when it returns; it changes when the caller applies what
// came back. A refresh in between reads the state from before that, and
// then races the caller's own refresh to publish it.
_, err = self.c.RunSubprocess(
self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile))
return err
}
func (self *FilesHelper) OpenDirInEditor(path string) error {
+2
View File
@@ -39,6 +39,7 @@ type Helpers struct {
Snake *SnakeHelper
// lives in context package because our contexts need it to render to main
Diff *DiffHelper
DiffLine *DiffLineHelper
Repos *ReposHelper
RecordDirectory *RecordDirectoryHelper
Update *UpdateHelper
@@ -76,6 +77,7 @@ func NewStubHelpers() *Helpers {
Commits: &CommitsHelper{},
Snake: &SnakeHelper{},
Diff: &DiffHelper{},
DiffLine: &DiffLineHelper{},
Repos: &ReposHelper{},
RecordDirectory: &RecordDirectoryHelper{},
Update: &UpdateHelper{},
@@ -3,6 +3,8 @@ package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
@@ -73,7 +75,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true)
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, git_commands.DiffModePlain)
if err != nil {
return
}
@@ -589,7 +589,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// this runs — and the keys buffered during the refresh replay —
// the refreshed state is in place.
self.c.OnUIThread(func() error {
return self.c.GocuiGui().EndBlockingEvents()
self.c.GocuiGui().EndBlockingEvents()
return nil
})
}
@@ -1,6 +1,7 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
@@ -53,8 +54,8 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
return
}
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false)
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true)
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, false)
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, true)
// grabbing locks here and releasing before we finish the function
// because pushing say the secondary context could mean entering this function
@@ -60,7 +60,7 @@ type WindowArrangementArgs struct {
ContentHeightForWindow func(window string) int
// Whether the main panel is split (as is the case e.g. when a file has both
// staged and unstaged changes)
SplitMainPanel bool
MainPanes types.MainPanes
// The current screen mode (normal, half, full)
ScreenMode types.ScreenMode
// The content shown on the bottom left of the screen when showing a loader
@@ -103,7 +103,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
ContentHeightForWindow: func(window string) int {
return self.windowHelper.GetContextForWindow(window).TotalContentHeight()
},
SplitMainPanel: repoState.GetSplitMainPanel(),
MainPanes: repoState.GetMainPanes(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
@@ -215,36 +215,27 @@ func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
}
func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
// if we're not in split mode we can just show the one main panel. Likewise if
// the main panel is focused and we're in full-screen mode
if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") {
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
mainPane := &boxlayout.Box{Window: "main", Weight: 1}
secondaryPane := &boxlayout.Box{Window: "secondary", Weight: 1}
switch args.MainPanes {
case types.MainPaneOnly:
return []*boxlayout.Box{mainPane}
case types.SecondaryPaneOnly:
return []*boxlayout.Box{secondaryPane}
case types.BothMainPanes:
// In full-screen mode the focused one takes the whole section anyway.
if args.ScreenMode == types.SCREEN_FULL {
if args.CurrentWindow == "main" {
return []*boxlayout.Box{mainPane}
}
if args.CurrentWindow == "secondary" {
return []*boxlayout.Box{secondaryPane}
}
}
}
if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL {
return []*boxlayout.Box{
{
Window: "secondary",
Weight: 1,
},
}
}
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
{
Window: "secondary",
Weight: 1,
},
}
return []*boxlayout.Box{mainPane, secondaryPane}
}
func getMidSectionWeights(args WindowArrangementArgs) (int, int) {
@@ -382,7 +373,7 @@ func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
}
func splitMainPanelSideBySide(args WindowArrangementArgs) bool {
if !args.SplitMainPanel {
if args.MainPanes != types.BothMainPanes {
return false
}
@@ -35,7 +35,7 @@ func TestGetWindowDimensions(t *testing.T) {
// Each panel shows its first tab by default; for the special-cased
// panels (status, stash) the view name matches the window name.
ActiveViewForWindow: func(window string) string { return window },
SplitMainPanel: false,
MainPanes: types.MainPaneOnly,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
@@ -727,7 +727,7 @@ func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
return &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(patch),
Task: types.NewMainViewDiffStringTask(patch),
Title: c.Tr.CustomPatch,
}
}
+902 -10
View File
@@ -3,7 +3,9 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type MainViewController struct {
@@ -12,6 +14,9 @@ type MainViewController struct {
context *context.MainContext
otherContext *context.MainContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
var _ types.IController = &MainViewController{}
@@ -21,12 +26,19 @@ func NewMainViewController(
context *context.MainContext,
otherContext *context.MainContext,
) *MainViewController {
return &MainViewController{
controller := &MainViewController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -38,10 +50,101 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.toggleSelectHunk,
DescriptionFunc: self.diffSelectionDescription(func() string {
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
}),
Description: self.c.Tr.ToggleSelectHunk,
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.toggleRangeSelect,
Description: self.c.Tr.ToggleRangeSelect,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editLine,
Description: self.c.Tr.EditFile,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.primaryAction,
Description: self.c.Tr.Stage,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Stage),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.StageSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
Description: self.c.Tr.DiscardSelection,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.DiscardSelection),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.DiscardSelectionTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk),
Handler: self.editHunk,
Description: self.c.Tr.EditHunk,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.EditHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditHunkTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedTextToClipboard,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.prevChangeBlock,
Description: self.c.Tr.PrevHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.nextChangeBlock,
Description: self.c.Tr.NextHunk,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
Handler: self.prevFile,
Description: self.c.Tr.PrevFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextFile),
Handler: self.nextFile,
Description: self.c.Tr.NextFileInDiff,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
Description: self.c.Tr.ExitFocusedMainView,
DescriptionFunc: self.escapeDescription,
DisplayOnScreen: true,
},
{
@@ -51,6 +154,54 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Description: self.c.Tr.StartSearch,
Tag: "navigation",
},
{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.RangeSelectUp),
Handler: self.extendRangeUp,
Description: self.c.Tr.RangeSelectUp,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.extendRangeDown,
Description: self.c.Tr.RangeSelectDown,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress),
Description: self.c.Tr.Commit,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Commit),
Tooltip: self.c.Tr.CommitTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress),
Description: self.c.Tr.CommitChangesWithoutHook,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithoutHook),
},
{
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress),
Description: self.c.Tr.CommitChangesWithEditor,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithEditor),
},
{
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress),
Description: self.c.Tr.FindBaseCommitForFixup,
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.FindBaseCommitForFixup),
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
},
{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},
}
}
@@ -68,6 +219,19 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
Handler: self.onClickInOtherViewOfMainViewPair,
FocusedView: self.otherContext.GetViewName(),
},
{
// Dragging after a click extends a range selection from the clicked line.
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.onDragInFocusedView,
FocusedView: self.context.GetViewName(),
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
}
}
@@ -76,35 +240,763 @@ func (self *MainViewController) Context() types.Context {
}
func (self *MainViewController) togglePanel() error {
if self.otherContext.GetView().Visible {
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if !self.otherContext.GetView().Visible {
return nil
}
// Whether the pair holds a diff is decided by the side panel beneath, which
// NextInStack only finds while our context is still the focused main view, so
// read it before pushing the other pane.
isDiff := self.isDiffView()
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if isDiff {
self.c.Helpers().DiffLine.EstablishSelection(self.otherContext, -1)
}
return nil
}
// escape dismisses the selection a step at a time before leaving the view: a range
// collapses to its cursor line, and hunk mode the user turned on goes back to
// line-by-line. Hunk mode that is merely the configured default is not something to
// escape from, so there escape leaves.
func (self *MainViewController) escape() error {
if self.selectingRange() || self.selectingHunkEnabledByUser() {
self.context.ResetDiffSelectMode()
return nil
}
self.c.Context().Pop()
return nil
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
func (self *MainViewController) escapeDescription() string {
if self.selectingRange() {
return self.c.Tr.DismissRangeSelect
}
if self.selectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.ExitFocusedMainView
}
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
// asked for it, as opposed to it being the configured default.
func (self *MainViewController) selectingHunkEnabledByUser() bool {
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
}
// isDiffView reports whether the focused main view currently shows a diff, and so
// shows a selection. See types.DiffMainViewContext.
func (self *MainViewController) isDiffView() bool {
return self.diffMainViewType() != types.DiffMainViewTypeNone
}
// diffMainViewType reports what the diff in the focused main view belongs to, taken
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
// stack or has no diff panel beneath it. The IsInStack guard is essential:
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
// leads here) also runs for off-stack panes — at startup and while generating the
// cheatsheets, where the stack is empty.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if !self.c.Context().IsInStack(self.context) {
return types.DiffMainViewTypeNone
}
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
}
// diffSource returns the panel beneath the focused main view, as the thing that can
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
// panel beneath shows no diff.
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
if !self.c.Context().IsInStack(self.context) {
return nil
}
sidePanel := self.c.Context().NextInStack(self.context)
if sidePanel == nil {
return nil
}
return sidePanel.GetFocusedMainViewDiffSource()
}
// focusedMainViewActions returns what the panel beneath the focused main view does to
// a selection in its diff, or nil where it does nothing to it — a panel whose diff can
// be read and copied but not acted on.
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
actions, _ := self.diffSource().(types.FocusedMainViewActions)
return actions
}
// primaryAction acts on the selected diff lines, leaving what that means to the panel
// beneath — which also re-renders the diff, since it is the one that changed it.
func (self *MainViewController) primaryAction() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.PrimaryAction(self.context, first, last)
}
// revealSelectionAfterAction moves the selection to the change that takes the place of
// the one just acted on, once the changed diff has re-rendered. Call it from the panel's
// action handler with the pane it acted in, the pane the work carries on in, and the
// buffer line the selection starts on, before triggering the re-render.
//
// The line acted on is gone from the diff, so what is remembered is its place among the
// diff's changes: the next change moves up into it, which is where you want to be to
// carry on. A range collapses to a single line at its start, and hunk mode selects the
// whole block it lands in, so that pressing the key again acts on the next hunk. The
// target pane inherits that select mode, this being the same piece of work continuing
// in another pane — and shows no selection until the restore places one, so that what
// it was left showing the last time it was used doesn't appear for a frame.
//
// done is called once the selection is where it belongs, or once it turns out that no
// render is coming to put it there, for a caller that must not let the user act again
// in between.
func revealSelectionAfterAction(
c *ControllerCommon, source types.DiffPaneContext, target types.DiffPaneContext,
firstBufferLine int, done func(),
) {
ordinal := c.Helpers().DiffLine.ChangeLineOrdinal(source.GetView(), firstBufferLine)
sel := source.DiffSelectState()
if sel.Mode == types.DiffSelectModeRange {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
}
*target.DiffSelectState() = *sel
selectHunk := sel.Mode == types.DiffSelectModeHunk
targetView := target.GetView()
if target != source {
target.SetHasSelectableContent(false)
c.Context().UpdateSelectionHighlights()
}
c.Helpers().DiffLine.RevealChangeLineAtOrdinal(targetView, ordinal, func(viewLine int) {
if selectHunk {
c.Helpers().DiffLine.SelectChangeBlock(target, viewLine, true)
return
}
targetView.CancelRangeSelect()
c.Helpers().DiffLine.ShowSelectionAtLine(targetView, viewLine, true)
}, done)
}
// discardSelection takes the selected diff lines back out of what they are part of,
// which — like the primary action — is the panel's business, and so is the re-render
// that follows.
func (self *MainViewController) discardSelection() error {
actions := self.focusedMainViewActions()
if actions == nil {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.DiscardSelection(self.context, first, last)
}
// editHunk hands the hunk around the selection to an editor, and is only offered over
// the working tree's diff: what comes back is applied to the index, which is not
// something a commit's diff has any use for.
func (self *MainViewController) editHunk() error {
actions, ok := self.diffSource().(*WorkingTreeDiffActions)
if !ok {
return nil
}
first, last, ok := self.context.GetView().SelectedBufferLineRange()
if !ok {
return nil
}
return actions.EditHunk(self.context, first, last)
}
// workingTreeAction wraps a command that acts on the working tree — committing, finding
// the commit to fix up — so that it only runs while the focused main view is showing the
// working tree's diff. Over a commit's diff the key does nothing, so that browsing
// through history can't commit by accident. The check is per press, since what the main
// view shows changes as the user moves around while the keybindings are registered once.
func (self *MainViewController) workingTreeAction(action func() error) func() error {
return func() error {
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
return nil
}
return action()
}
}
// workingTreeActionDescription gives a command's description only where the command
// applies — over the working tree's diff — so that it is listed there and nowhere else.
func (self *MainViewController) workingTreeActionDescription(description string) func() string {
return func() string {
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
return ""
}
return description
}
}
// copySelection copies the selected diff lines to the clipboard — not as the diff
// renderer drew them, but as they read in the diff itself, which is both what you meant
// to copy and the only form a renderer can't have mangled. A selection that is all
// additions or all deletions loses its +/- column, so that it can be pasted straight
// into code.
//
// The rows above the diff (a commit's message, git's summary of it) belong to no file,
// so they are copied as they stand on screen.
func (self *MainViewController) copySelection() error {
text := self.textOfSelection()
if text == "" {
self.c.ErrorToast(self.c.Tr.SelectionNotFoundInDiffToast)
return nil
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
if err := self.c.OS().CopyToClipboard(text); err != nil {
return err
}
self.c.Toast(self.c.Tr.SelectedDiffLinesCopiedToast)
return nil
}
// textOfSelection is what copying the selection puts on the clipboard. It is "" when
// none of the selected rows could be placed in the diff, which is what a rendering's
// own decoration comes to.
func (self *MainViewController) textOfSelection() string {
source := self.diffSource()
if source == nil {
return ""
}
view := self.context.GetView()
first, last, ok := view.SelectedBufferLineRange()
if !ok {
return ""
}
aboveDiff, fromDiff := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last,
func(paths []string) string { return source.PlainDiff(self.context, paths) })
if aboveDiff == "" {
// Only text that is all diff has a +/- column to lose: a line of a commit message
// may begin with a '-' without being a deletion of anything.
fromDiff = dropDiffPrefix(fromDiff)
}
return aboveDiff + fromDiff
}
// diffSelectState returns this pane's diff selection mode state.
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
return self.context.DiffSelectState()
}
// diffSelectionDescription qualifies the description of a command that acts on the
// selection, so that it is listed only where it applies: the main view also shows
// content with nothing to select in it — a branch's commit log, the status dashboard —
// and a command with no description is left out of the keybindings menu.
//
// The static Description stays as it is: the cheatsheets are generated from that, and
// they document what a key does rather than when it applies.
func (self *MainViewController) diffSelectionDescription(describe func() string) func() string {
return func() string {
if !self.isDiffView() {
return ""
}
return describe()
}
}
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
return self.diffSelectionDescription(func() string { return description })
}
// diffSelectionDisabledReason disables the commands that act on the selection while
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
// file, an empty commit) or which is showing a placeholder message.
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
if !self.context.GetView().Highlight {
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
}
return nil
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
self.selectClickedDiffLine(opts.Y)
return nil
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
// would otherwise still be the default single line until it had been focused at
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
// where the click landed.
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
self.c.Context().Push(self.context, types.OnFocusOpts{})
self.selectClickedDiffLine(opts.Y)
return nil
}
// onDragInFocusedView extends a range selection as the mouse is dragged after a
// click, anchored at the line the click landed on rather than wherever the click left
// the selection — a click can select a whole hunk, whose far end would otherwise
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
// clicked line to the line under the cursor, which gocui has already moved here.
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
view := self.context.GetView()
if !self.isDiffView() || !view.Highlight {
return nil
}
sel := self.diffSelectState()
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
// A drag that reaches the edge of the view keeps going: mouse capture means the
// pointer can be dragged past the edge, and there is more diff down there than
// fits on screen. opts.Y is where the pointer is in the content, which the
// autoscroller wants relative to the viewport.
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
// The drag moved the selection without going through showSelectionAtLine: gocui
// moves the cursor for it. Let the search catch up with where it ended.
self.context.GetView().SetNearestSearchPosition()
return nil
}
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
// otherwise the pointer would keep driving a view that no longer has focus.
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
// actually extending a range in a diff. Scrolling down also has to keep the lazily
// loaded content ahead of the scroll, or it would stop at the loaded edge.
func (self *MainViewController) canDragAutoscroll(direction int) bool {
if !self.draggingWithMouse || !self.isDiffView() {
return false
}
view := self.context.GetView()
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
if direction > 0 {
self.c.ReadLinesToFillView(view)
}
return true
}
// handleDragAutoscroll extends the selection to the line the pointer ends up over
// after the autoscroller has scrolled, leaving the range anchored where the drag
// started. It reports whether the autoscroll should carry on.
//
// The pointer is usually outside the view by now — that is what mouse capture is for —
// so the line it is over is clamped to the visible ones, leaving the selection's far
// end at the edge the scroll is moving towards.
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
if !self.canDragAutoscroll(0) {
return false
}
view := self.context.GetView()
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
return true
}
// selectClickedDiffLine sets the focused main view's selection from a click at the
// given view line. In hunk mode, clicking inside the selected block collapses it to
// that line; clicking a change line outside it keeps hunk mode and selects that block.
// A click on context, or any click outside hunk mode, selects just that line too.
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
if !self.isDiffView() {
return
}
view := self.context.GetView()
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
self.context.SetDragAnchorViewLine(viewLine)
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
viewLine >= start && viewLine <= end {
self.context.ResetDiffSelectMode()
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
return
}
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
self.selectHunkAround(viewLine, false)
return
}
}
self.context.ResetDiffSelectMode()
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
}
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, scrollIntoView)
}
// navigate moves the focused main view to the row find locates from the current
// anchor — the selected line when a selection is showing, otherwise the top visible
// line. With a selection we move it there and scroll it into view, re-selecting the
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
// to the top without selecting anything.
// alignTop says what a jump does with a target it has to scroll to: bring it to the
// top of the view, or leave the scrolling to place it as it sees fit.
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
v := self.context.GetView()
anchor := v.OriginY()
if v.Highlight {
anchor = v.SelectedLineIdx()
}
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
return
}
if !forward {
// Everything above the anchor has loaded, so a backward target that wasn't
// found doesn't exist.
return
}
// The diff loads lazily, so a target below the loaded portion isn't there to be
// found yet. Read the rest of it in and look again before concluding there is none.
manager := self.c.GetViewBufferManagerForView(v)
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
}
return nil
})
})
}
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
// the anchor view line to start from, and the direction.
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
func (self *MainViewController) nextChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
return nil
}
func (self *MainViewController) prevChangeBlock() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
return nil
}
// nextFile and prevFile bring the file they go to to the top of the view, since what
// you are going there for is the file, and the more of it is on screen the better.
func (self *MainViewController) nextFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
return nil
}
func (self *MainViewController) prevFile() error {
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
return nil
}
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
// screen if it isn't already.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.collapseNonStickyRange()
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
self.selectHunkAround(target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
}
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *MainViewController) scrollTargetToTop(target int) {
view := self.context.GetView()
originY, height := self.context.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
// configured scroll-off margin, reading more content in first when moving down. The
// range anchor is left untouched, so this extends or contracts a range and just moves
// the selected line otherwise.
func (self *MainViewController) moveCursor(delta int) {
v := self.context.GetView()
if delta > 0 {
self.c.ReadLinesToFillView(v)
}
before := v.SelectedLineIdx()
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
if delta == -1 {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
} else if delta == 1 {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, after, true)
}
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
// sticky range is kept, so the move extends it.
func (self *MainViewController) collapseForLineMove() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
return
}
self.collapseNonStickyRange()
}
// collapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *MainViewController) collapseNonStickyRange() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
}
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
// a single line on a plain move. With no selection — non-diff content — it scrolls.
func (self *MainViewController) adjustSelection(delta int) {
if !self.context.GetView().Highlight {
self.handleLineChange(delta)
return
}
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
return
}
self.collapseForLineMove()
self.moveCursor(delta)
}
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
func (self *MainViewController) selectAbsoluteLine(target int) {
self.collapseForLineMove()
self.c.Helpers().DiffLine.ShowSelectionAtLine(self.context.GetView(), target, true)
}
// selectingRange reports whether a range selection is currently active: we're in
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
// non-sticky range that has actually been extended.
func (self *MainViewController) selectingRange() bool {
if self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
start, end := self.context.GetView().SelectedLineRange()
return self.diffSelectState().RangeIsSticky || start != end
}
// toggleSelectHunk switches between selecting the change block around the cursor and
// a single line.
func (self *MainViewController) toggleSelectHunk() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeHunk
sel.UserEnabledHunkMode = true
self.selectHunkAround(v.SelectedLineIdx(), true)
}
return nil
}
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
// up/down keys extend.
func (self *MainViewController) toggleRangeSelect() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if self.selectingRange() {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = true
v.SetRangeSelectStart(v.SelectedLineIdx())
}
return nil
}
// extendRange grows a non-sticky range selection by one line in response to
// shift+up/down, starting one at the cursor if there isn't one yet.
func (self *MainViewController) extendRange(forward bool) error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if !self.selectingRange() {
sel.Mode = types.DiffSelectModeRange
v.SetRangeSelectStart(v.SelectedLineIdx())
}
sel.RangeIsSticky = false
if forward {
self.moveCursor(1)
} else {
self.moveCursor(-1)
}
return nil
}
func (self *MainViewController) extendRangeUp() error {
return self.extendRange(false)
}
func (self *MainViewController) extendRangeDown() error {
return self.extendRange(true)
}
func (self *MainViewController) handleLineChange(delta int) {
v := self.context.GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *MainViewController) handlePrevLine() error {
self.adjustSelection(-1)
return nil
}
func (self *MainViewController) handleNextLine() error {
self.adjustSelection(1)
return nil
}
func (self *MainViewController) handlePrevPage() error {
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleNextPage() error {
self.adjustSelection(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleGotoTop() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(0)
return nil
}
func (self *MainViewController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
return nil
})
})
}
return nil
}
func (self *MainViewController) editLine() error {
view := self.context.GetView()
if !view.Highlight {
return nil
}
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
// A file-header row points at the file as a whole rather than at a line in it, so
// it opens the file without jumping anywhere — as pressing edit on a file in a side
// panel does.
if info.Type == types.DiffLineFileHeader {
return self.c.Helpers().Files.EditFiles([]string{info.Path})
}
// The diff may be of an older commit, whose line numbers aren't the file's current
// ones, so they have to be carried forward before we can point an editor at them.
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
@@ -37,6 +37,20 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
return self.c.Contexts().ReflogCommits
}
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the reflog entry's diff for the given files — the same diff its
// main view shows, only without the entry's message and stat above it.
func (self *ReflogCommitsController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
commit := self.context().GetSelected()
if commit == nil {
return ""
}
return self.c.Helpers().Diff.PlainDiffBetweenRefs(commit.ParentRefName(), commit.RefName(), paths)
}
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
@@ -45,9 +59,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))
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), mode)
task = types.NewRunDiffRendererTask(cmdObj.GetCmd())
task = types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
}
self.c.RenderToMainViews(types.RefreshMainOpts{
+4 -2
View File
@@ -92,10 +92,12 @@ func (self *StashController) GetOnRenderToMain() func() {
if stashEntry == nil {
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
} else {
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
task = types.NewRunDiffRendererTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
task = types.NewMainViewDiffTaskWithPrefix(
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(),
prefix,
mode,
)
}
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
@@ -123,7 +125,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, file.Names())
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, git_commands.DiffModeRendered, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}
@@ -4,6 +4,7 @@ import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -64,6 +65,22 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
}
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the diff of the panel's selected commit (or range of them) for
// the given files — the same diff its main view shows, only without the commit's
// message and stat above it.
func (self *SwitchToDiffFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
ref := self.context.GetSelectedRef()
if ref == nil {
return ""
}
from, to := context.FromAndToForDiff(ref, self.context.GetSelectedRefRangeForDiffFiles())
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
}
func (self *SwitchToDiffFilesController) enter() error {
ref := self.context.GetSelectedRef()
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
@@ -1,7 +1,9 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -61,21 +63,43 @@ 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)
// Usually the main pane, but the content can be in the secondary one alone: a file
// with nothing but staged changes shows them there.
mainViewContext := self.c.Contexts().Normal
if self.c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly {
mainViewContext = self.c.Contexts().NormalSecondary
}
return self.focusMainView(mainViewContext, -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 _, ok := self.context.(types.DiffMainViewContext); !ok {
return nil
}
// The diff on screen was produced for reading, and the renderer that produced it may
// have laid it out in a way that says nothing about which line of which file each row
// is. Now that the user wants to act on it, it is re-rendered as git's own diff — the
// panel below decides that for itself, from the same question — and the selection
// goes on that instead of on rows we can't place.
if self.c.Helpers().DiffLine.MainViewDiffMode() == git_commands.DiffModeRaw {
self.c.Helpers().DiffLine.RenderFocusedMainViewAgain(mainViewContext.GetView(), self.context, func() {
self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
})
return nil
}
self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
return nil
}
@@ -27,6 +27,12 @@ func (self *ToggleWhitespaceAction) Call() error {
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
// You toggle this to see whether what you are looking at is more than
// reindentation, so that is the thing to keep in front of you — even though
// ignoring whitespace, unlike the other ways of re-rendering a diff, can take
// the line away entirely along with the hunk or file it was in.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
self.c.Context().CurrentSide().HandleRenderToMain()
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
}
@@ -0,0 +1,414 @@
package controllers
import (
"fmt"
"path/filepath"
"strings"
"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/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// WorkingTreeDiffActions implements what the files panel offers on the diff it renders
// into the focused main view: the diff itself, for the commands that need to read lines
// out of it rather than off the screen.
type WorkingTreeDiffActions struct {
c *ControllerCommon
}
var _ types.FocusedMainViewActions = &WorkingTreeDiffActions{}
func NewWorkingTreeDiffActions(c *ControllerCommon) *WorkingTreeDiffActions {
return &WorkingTreeDiffActions{c: c}
}
func (self *WorkingTreeDiffActions) context() *context.WorkingTreeContext {
return self.c.Contexts().Files
}
// PlainDiff hands out the working tree's diff for the given files, taken from the
// side of the index that the asking pane shows.
func (self *WorkingTreeDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
node := self.context().GetSelected()
if node == nil {
return ""
}
// An error means there is no diff to be had, which for our purposes is the same as
// an empty one.
diff, _ := self.c.Git().WorkingTree.
WorktreeFileDiffCmdObj(node, git_commands.DiffModePlain, self.showsStagedSide(pane), paths).
RunWithOutput()
return diff
}
// showsStagedSide reports whether the given main pane is the one showing the staged
// side of a file's diff, which is always the lower one.
func (self *WorkingTreeDiffActions) showsStagedSide(pane types.DiffPaneContext) bool {
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
}
// PrimaryAction stages the selected diff lines, or takes them back out of the index
// when what is selected is the staged side of the diff.
func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine 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(pane, firstBufferLine, lastBufferLine)
if !ok {
return nil
}
// Either way the patch goes to the index: forwards from the unstaged side to stage
// it, backwards from the staged side to take it back out.
return self.applyDiffLineSelection(pane, firstBufferLine, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
}
// DiscardSelection takes the selected diff lines out of the working tree — or, on the
// staged side, out of the index, which is where "discard this" means "I don't want it
// staged".
func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine 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(pane, firstBufferLine, lastBufferLine)
if !ok {
return nil
}
// Either way the change is applied backwards; which side it is applied to decides
// how destructive that is. On the staged side it goes to the index; this is the
// same as unstaging, so nothing is lost. On the unstaged side it goes to the
// working tree, where the change is gone for good, so we ask first.
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(pane, firstBufferLine, infos, onStagedSide,
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
},
})
}
// EditHunk opens the git hunk holding the selection in an editor, as a patch against
// the index, and applies whatever comes back. It is how you stage something the diff
// can't express — half of a changed line, or a change written differently from either
// side — since what the editor hands back is applied rather than matched against the
// file's own diff.
//
// The hunk is the git one, context and all, rather than lazygit's block of adjacent
// changes: an editable patch is one that still applies, and the context lines are what
// let git place it.
func (self *WorkingTreeDiffActions) EditHunk(
pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int,
) error {
infos, onStagedSide, ok := self.diffLineSelection(pane, firstBufferLine, lastBufferLine)
if !ok {
return nil
}
file := self.fileForDiffLinePath(infos[0].Path)
if file == nil {
return nil
}
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, onStagedSide))
lineIndices := changeLineIndices(parsedPatch, infos[:1])
if len(lineIndices) == 0 {
return nil
}
hunkIdx := parsedPatch.HunkContainingLine(lineIndices[0])
hunkStartIdx := parsedPatch.HunkStartIdx(hunkIdx)
patchText := parsedPatch.
Transform(patch.TransformOpts{
Reverse: onStagedSide,
IncludedLineIndices: patch.ExpandRange(hunkStartIdx, parsedPatch.HunkEndIdx(hunkIdx)),
FileNameOverride: file.GetPath(),
}).
FormatPlain()
patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
// The patch is written with a two-line header before its hunk, so the line the
// user was on sits that much further down the file they are about to edit.
const headerLineCount = 2
if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath,
lineIndices[0]-hunkStartIdx+headerLineCount+1); err != nil {
return err
}
editedPatchText, err := self.c.Git().File.Cat(patchFilepath)
if err != nil {
return err
}
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
// Everything the editor left behind is taken, this being a patch the user wrote
// rather than a selection out of one of ours.
lineCount := strings.Count(editedPatchText, "\n") + 1
newPatchText := patch.
Parse(editedPatchText).
Transform(patch.TransformOpts{
IncludedLineIndices: patch.ExpandRange(0, lineCount),
FileNameOverride: file.GetPath(),
}).
FormatPlain()
if err := self.c.Git().Patch.ApplyPatch(newPatchText, git_commands.ApplyPatchOpts{
Reverse: onStagedSide,
Cached: true,
}); err != nil {
return err
}
// Block input until the refresh has landed, as the staging commands do: the diff is
// about to be rebuilt from a file that no longer looks the way it did.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return nil
}
// diffLineSelection resolves what the user has selected in a pane of the focused main
// view to the change lines to act on, and reports whether they are the staged side of
// the diff — which is a question about the pane, so it is the same for every file of a
// directory's diff. ok is false when the selection holds no change line, in which case
// there is nothing to act on.
func (self *WorkingTreeDiffActions) diffLineSelection(
pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int,
) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
infos = self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
if len(infos) == 0 {
return nil, false, false
}
return infos, self.showsStagedSide(pane), true
}
// applyDiffLineSelection applies the selected change lines, a patch per file, and
// re-renders what that changed. onStagedSide says which of the file's two diffs the
// lines were selected in and so are to be found in; opts says how to apply them.
// firstBufferLine is where the selection started, which is where the work carries on
// from once the diff has changed under it.
func (self *WorkingTreeDiffActions) applyDiffLineSelection(
pane types.DiffPaneContext, firstBufferLine int,
infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts,
) error {
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
// A directory's diff spans several files, and a patch is of one file, so the
// selected lines are grouped by the file they belong to and applied file by file.
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
acted := set.New[string]()
actedSideRemains := false
for path, fileInfos := range infosByFile {
file := self.fileForDiffLinePath(path)
if file == nil {
continue
}
changesLeft, err := self.applyDiffLines(file, fileInfos, onStagedSide, opts)
if err != nil {
return err
}
acted.Add(file.GetPath())
actedSideRemains = actedSideRemains || changesLeft
}
if !actedSideRemains {
actedSideRemains = self.anyFileHasChangesOnSide(acted, onStagedSide)
}
// Whether the other side has anything decides which pane the work carries on in.
// If the lines were staged, they are in the index now, so that side has them. If
// they were discarded, the other side was not touched, so the model still
// describes it correctly.
otherSideHasChanges := opts.Cached || self.anyFileHasChangesOnSide(set.New[string](), !onStagedSide)
// The refresh below queues the re-render of the diff we just changed; this rides it,
// so that the selection ends up on the change that took the place of the one acted
// on rather than at a position that means nothing any more — and in the pane the
// work carries on in, which is not always the one it was in.
self.revealSelectionInPaneItLandsIn(pane, firstBufferLine, actedSideRemains, otherSideHasChanges)
// Block input until the refresh has landed, so that a quick second keypress acts on
// the diff as it now is rather than on the one we just changed.
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return nil
}
// fileForDiffLinePath maps the absolute path a diff line carries to the working tree
// file it belongs to, or nil for a path that is no file of this repo's working tree.
func (self *WorkingTreeDiffActions) 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 the given change lines of one file — a line, a hunk, a range —
// as a patch built from that file's own diff:
//
// - stage: read the unstaged diff, apply it to the index
// - unstage: read the staged diff, apply it to the index backwards
//
// sourceCached names the diff the lines were selected in, which is where they are found
// again; opts says how to apply what is built from them. The two are independent — a
// discard reads one side and reverses it — so they are passed separately.
//
// Each selected line is looked for by where it sits in the file. This tells the two
// halves of a modified line apart: the deletion and the addition replacing it share a
// position in the new file and differ only in being a deletion. Context lines are not
// selected: a patch of the lines you picked keeps whatever context it needs around
// them by itself.
//
// It reports whether the diff it read holds changes the selection didn't cover. The
// caller uses this to tell whether the side acted on still has anything of this file
// in it once we are done.
func (self *WorkingTreeDiffActions) applyDiffLines(
file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts,
) (bool, error) {
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached))
patchLineIndices := changeLineIndices(parsedPatch, infos)
changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch)
// Acting on every change of a file is acting on the file itself, and saying so is
// not the same as applying its diff. The diff of a deleted file is its content
// going away, and putting that into the index line by line leaves an empty file
// there rather than the deletion; the diff of an added one is its whole content,
// and taking that back out leaves an empty file in the index rather than an
// untracked one.
if !changesLeft && opts.Cached {
if opts.Reverse {
return false, self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked)
}
return false, self.c.Git().WorkingTree.StageFile(file.GetPath())
}
patchToApply := parsedPatch.
Transform(patch.TransformOpts{
Reverse: opts.Reverse,
IncludedLineIndices: patchLineIndices,
FileNameOverride: file.GetPath(),
}).
FormatPlain()
if patchToApply == "" {
return changesLeft, nil
}
return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts)
}
// changeLineIndices says which lines of a parsed patch the given diff rows are, as the
// indices a patch is built in terms of. Each row is looked for by where it sits in the
// file: an addition by its place in the new version, a deletion by its place in the
// old one, which is what tells the two halves of a changed line apart.
func changeLineIndices(parsedPatch *patch.Patch, infos []types.DiffLineInfo) []int {
type changeLine struct {
lineNumber int
isDeletion bool
}
selected := set.New[changeLine]()
for _, info := range infos {
if info.Type == types.DiffLineDeleted {
selected.Add(changeLine{info.OldLine, true})
} else {
selected.Add(changeLine{info.NewLine, false})
}
}
var indices []int
for idx, line := range parsedPatch.Lines() {
var key changeLine
switch {
case line.IsAddition():
key = changeLine{parsedPatch.LineNumberOfLine(idx), false}
case line.IsDeletion():
key = changeLine{parsedPatch.OldLineNumberOfLine(idx), true}
default:
continue
}
if selected.Includes(key) {
indices = append(indices, idx)
}
}
return indices
}
// changeLineCount returns how many of a patch's lines are changes rather than context
// or header. A selection of the whole diff covers exactly that many.
func changeLineCount(p *patch.Patch) int {
return lo.CountBy(p.Lines(), func(line *patch.PatchLine) bool {
return line.IsAddition() || line.IsDeletion()
})
}
// revealSelectionInPaneItLandsIn arranges for the selection to carry on where the work
// does, which is not always the pane it was in.
//
// Each side of the diff has a pane of its own, so acting on one usually leaves
// everything where it is. But a pane is only shown while its side has something in it:
// staging the last unstaged change takes the upper pane away, and unstaging the last
// staged one takes the lower one away. The refresh moves the focus into whichever pane
// is left, and this puts the selection there to meet it — on the lines just acted on,
// which are in that pane now, unless they were discarded rather than moved, in which
// case on what is left of the file.
func (self *WorkingTreeDiffActions) revealSelectionInPaneItLandsIn(
pane types.DiffPaneContext, firstBufferLine int, actedSideRemains bool, otherSideHasChanges bool,
) {
target := pane
if !actedSideRemains && otherSideHasChanges {
target = self.otherPane(pane)
}
// Hold input back until the selection is on the change the work carries on from. The
// refresh holds it until the model is up to date, but the diff is re-rendered after
// that, and until it has been the selection is still on lines that aren't there any
// more — so a key pressed meanwhile would act on nothing.
self.c.GocuiGui().BeginBlockingEvents()
revealSelectionAfterAction(self.c, pane, target, firstBufferLine,
self.c.GocuiGui().EndBlockingEvents)
}
// otherPane returns the main pane that isn't the given one.
func (self *WorkingTreeDiffActions) otherPane(pane types.DiffPaneContext) types.DiffPaneContext {
if pane.GetKey() == self.c.Contexts().Normal.GetKey() {
return self.c.Contexts().NormalSecondary
}
return self.c.Contexts().Normal
}
// anyFileHasChangesOnSide reports whether any file under the selected node, other than
// the ones named by except, has changes on the given side of the index, as the model
// has them. The model is right about any file the action didn't touch; the ones it did
// touch report for themselves, their entry not being right until the refresh lands.
func (self *WorkingTreeDiffActions) anyFileHasChangesOnSide(except *set.Set[string], staged bool) bool {
node := self.context().GetSelected()
if node == nil {
return false
}
found := false
_ = node.ForEachFile(func(file *models.File) error {
if except.Includes(file.GetPath()) {
return nil
}
if (staged && file.HasStagedChanges) || (!staged && file.HasUnstagedChanges) {
found = true
}
return nil
})
return found
}
+13 -12
View File
@@ -22,12 +22,7 @@ func (gui *Gui) scrollDownView(view *gocui.View) {
}
func (gui *Gui) scrollUpMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
// although we have this same logic in the controller, this method can be invoked
@@ -43,12 +38,7 @@ func (gui *Gui) scrollUpMain() error {
}
func (gui *Gui) scrollDownMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
gui.State.Contexts.MergeConflicts.SetUserScrolling(true)
@@ -59,6 +49,17 @@ func (gui *Gui) scrollDownMain() error {
return nil
}
// mainSectionView returns the view that the keys for scrolling the main section act
// on: the pane the focus is in when it is in one of them, and otherwise the pane the
// section is showing — which is the lower one whenever it has the section to itself.
func (gui *Gui) mainSectionView() *gocui.View {
if gui.c.Context().Current().GetWindowName() == "secondary" ||
gui.State.MainPanes == types.SecondaryPaneOnly {
return gui.secondaryView()
}
return gui.mainView()
}
func (gui *Gui) mainView() *gocui.View {
viewName := gui.helpers.Window.GetViewNameForWindow("main")
view, _ := gui.g.View(viewName)
+9 -5
View File
@@ -101,6 +101,10 @@ type Gui struct {
// this tells us whether our views have been initially set up
ViewsSetup bool
// the label for the key that focuses the main view, worn by whichever of the two
// main panes that key focuses (see showFocusMainViewJumpLabelOn)
focusMainViewJumpLabel string
Views types.Views
// Log of the commands/actions logged in the Command Log panel.
@@ -231,7 +235,7 @@ type GuiRepoState struct {
Model *types.Model
Modes *types.Modes
SplitMainPanel bool
MainPanes types.MainPanes
SearchState *types.SearchState
// Lets us not load everything at once. Written and read from refresh
@@ -321,12 +325,12 @@ func (self *GuiRepoState) GetSearchState() *types.SearchState {
return self.SearchState
}
func (self *GuiRepoState) SetSplitMainPanel(value bool) {
self.SplitMainPanel = value
func (self *GuiRepoState) SetMainPanes(value types.MainPanes) {
self.MainPanes = value
}
func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel
func (self *GuiRepoState) GetMainPanes() types.MainPanes {
return self.MainPanes
}
func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error {
+4
View File
@@ -185,6 +185,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View
return self.gui.getViewBufferManagerForView(view)
}
func (self *guiCommon) GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
return self.gui.getManager(view)
}
func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
self.gui.readLinesToFillView(view)
}
+320 -4
View File
@@ -4,6 +4,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
)
func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
@@ -65,6 +66,10 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
view.Subtitle = opts.SubTitle
if mainContext := gui.mainContextForView(view); mainContext != nil {
mainContext.SetContentIsDiff(types.ContentIsDiff(opts.Task))
}
if err := gui.runTaskForView(view, opts.Task); err != nil {
gui.c.Log.Error(err)
}
@@ -108,16 +113,26 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
}
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
panes := mainPanesFor(opts)
// Before the render is triggered, so that the pane the focus moves into can be
// told where to put its selection as it renders.
gui.followFocusIntoWorkablePane(opts)
gui.moveMainContextPairToTop(opts.Pair)
gui.handOverMainSection(opts.Pair, panes)
if opts.Main != nil {
gui.RefreshMainView(opts.Main, opts.Pair.Main)
} else {
gui.clearMainView(opts.Pair.Main)
}
if opts.Secondary != nil {
gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary)
} else if opts.Pair.Secondary != nil {
opts.Pair.Secondary.GetView().Clear()
gui.clearMainView(opts.Pair.Secondary)
}
// Reset the scroll positions of all the other main views. We do this after
@@ -134,11 +149,312 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
}
}
gui.splitMainPanel(opts.Secondary != nil)
gui.setMainPanes(panes)
}
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
gui.State.SplitMainPanel = splitMainPanel
// handOverMainSection carries the content of the main section from the pane that has
// been showing it on its own to the pane about to, when a render moves the section's
// content from one to the other — a file's changes going from unstaged to staged, say.
//
// The section is one region of the screen to the user, so a change of which pane holds
// it has to look like that region re-rendering rather than blanking and filling in
// again: the incoming pane shows what the outgoing one was showing, where it was
// showing it, until its own render has read enough to be swapped in. It renders from
// the top when it does, the content it took over not being its own (see
// clearMainView).
func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) {
// The lower pane is always the same view, being the only one a render can leave
// holding the section on its own; the upper one is whichever view of the main
// window this render is for, which moveMainContextPairToTop has just given a copy
// of what that window was showing.
upper, lower := pair.Main.GetView(), gui.Views.Secondary
var from, to *gocui.View
switch {
case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly:
from, to = upper, lower
case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly:
from, to = lower, upper
default:
return
}
gui.g.CopyContent(from, to)
}
// mainPanesFor says which panes the given render occupies: the one it has content for,
// or both when it has content for both.
func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
switch {
case opts.Secondary == nil:
return types.MainPaneOnly
case opts.Main == nil:
return types.SecondaryPaneOnly
default:
return types.BothMainPanes
}
}
// followFocusIntoWorkablePane moves the focus out of a main pane that the render about
// to happen leaves nothing to work on, and into the one it does.
//
// Each side of a file's diff has a pane of its own, and a pane holds something only
// while its side of the file does. So anything that empties the side the focus is on
// leaves that pane with nothing: staging the last unstaged change, committing what was
// staged, or either of those happening outside lazygit and arriving with a refresh.
// Usually the pane goes away with its content; configured to always split the diff it
// stays, empty. Either way the focus has nothing left to act on where it is.
//
// The pane moved into gets its selection once the render has finished and there is
// something to put one on, and shows none until then, so that the selection it was
// left with the last time it was used doesn't appear for a frame. A pane that has
// already been told where to put its selection — by the action that caused all this —
// keeps what it was told.
func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) {
// The focused main view's two panes only: the staging and patch-building views
// arrange theirs for themselves, and the merge-conflicts view has just the one.
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
return
}
current := gui.State.ContextMgr.CurrentStatic().GetKey()
if current != opts.Pair.Main.GetKey() && current != opts.Pair.Secondary.GetKey() {
return
}
pane := onlyWorkablePane(opts)
if pane == nil || pane.GetKey() == current {
return
}
target := gui.mainContextForView(pane.GetView())
target.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getManager(target.GetView()); !manager.HasRestoreForNextTask() {
manager.SetRestoreForNextTask(&tasks.RenderRestore{
// The whole render is read before it is shown: where the selection goes
// is decided from what is there, and a change line further down would
// otherwise be missed.
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) {
swapIn()
gui.helpers.DiffLine.EstablishSelection(target, -1)
},
})
}
gui.State.ContextMgr.Push(target, types.OnFocusOpts{})
}
// onlyWorkablePane returns the main pane a render leaves as the only one worth having
// the focus in, or nil when that is true of both of them or of neither. Being shown is
// not the same as being worth working in: a pane the layout keeps around for the sake
// of always splitting the diff shows an empty side of the file.
func onlyWorkablePane(opts types.RefreshMainOpts) types.Context {
main := opts.Main != nil && !opts.Main.NothingToActOn
secondary := opts.Secondary != nil && !opts.Secondary.NothingToActOn
if main == secondary {
return nil
}
if main {
return opts.Pair.Main
}
return opts.Pair.Secondary
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
// content when the render that just finished left the diff with fewer lines than the
// selection was on — a diff renderer that renders the same diff more compactly, a
// smaller context size. That selection lives in the view rather than in a model, so
// nothing else re-derives it, and past the end of the content it isn't drawn at all,
// which reads as having no selection until an arrow key brings it back.
//
// Called at end of input, when the content is final: doing it while the render is
// still loading would drag the selection to a line that only looks like the last one.
// Only these two views need it; every other view's selection is derived from a model
// as it renders, and so is clamped along with it.
func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
if gui.mainContextForView(view) == nil {
return
}
if !view.Highlight {
return
}
if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine {
view.FocusPoint(0, max(0, lastLine), true)
}
}
// clearMainView empties a pane that is being given nothing to show, selection and all.
//
// An emptied pane is showing nothing, so it also goes back to the top and stops
// claiming the render it was showing: whatever it is given next is content the user
// hasn't seen there, and is shown from the top like any other.
//
// A position waiting to be put back goes too: this pane is getting no render for it
// to ride, and whoever is waiting for the view to be back where it belongs has to
// hear that it never will be.
func (gui *Gui) clearMainView(mainContext types.Context) {
view := mainContext.GetView()
view.Clear()
view.SetOrigin(0, 0)
mainContext.SetHasSelectableContent(false)
if pane := gui.mainContextForView(view); pane != nil {
pane.SetContentIsDiff(false)
}
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getViewBufferManagerForView(view); manager != nil {
manager.ForgetRenderedContent()
manager.DropRestoreForNextTask()
}
}
// updateDiffSelectionVisibility works out whether a main pane holds anything for a
// selection to sit on, from what it is now showing: only beneath a panel whose main
// view is a diff, only while the pane is showing that diff rather than a message like
// "No changed files", and only while the diff holds something to select — never over
// one with nothing in it, such as a binary file's or an empty commit's. Whether the
// selection is then drawn, and drawn as the active one, follows from the context stack.
//
// It is asked wherever the pane's content changes: as a string is rendered, at the
// paint that reveals a command's output, with every further batch of that output, and
// once it has been read to the end. contentIsComplete tells those apart, since a render
// still being read can leave the question open (see diffPaneHasSomethingToSelect). The
// pane never answers from the render before it, and a render that leaves the question
// open is read on until it doesn't, so the answer is always about what is there.
func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplete bool) {
mainContext := gui.mainContextForView(view)
if mainContext == nil {
return
}
gui.dropAnAnswerAboutAnotherRender(mainContext, view)
if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect(
mainContext, view, contentIsComplete,
); known {
mainContext.SetHasSelectableContent(hasSomethingToSelect)
gui.State.ContextMgr.UpdateSelectionHighlights()
} else {
gui.readOnUntilTheDiffPaneCanTell(view)
}
}
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
// of an earlier render, so that this one starts from no answer rather than inheriting
// one. An answer about other content says nothing about this content: carried over, it
// shows a selection over a diff that may have nothing to select, or hides one over a
// diff that has.
//
// A re-render of the same content keeps its answer, and with it the selection drawn
// over it, since that answer is still about what the pane is showing.
func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() {
mainContext.SetSelectableContentRenderKey(key)
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
}
// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of
// it, while the pane still can't say whether there is anything in it to select.
//
// A render is asked for as many lines as the scrollbar needs (see
// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the
// diffstat of a commit touching thousands of files, or a commit message thousands of
// lines long. Without this the pane would be left with no answer until the user
// scrolled far enough to ask for the rest themselves, which is no way to find out
// whether a diff can be acted on. Another render's worth is asked for each time, so the
// reading stops soon after the first change line, and only runs to the end of a diff
// that has none.
func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
step := gui.linesToReadFromCmdTask(view).Total
if step < 0 {
// A view that is being searched is already being read to the end.
return
}
manager.ReadLinesAndWait(view.LinesHeight() + step)
}
// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a
// selection to sit on, from what it is showing so far. known is false while a render
// still being read leaves the question open.
func (gui *Gui) diffPaneHasSomethingToSelect(
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
) (bool, bool) {
if !mainContext.ContentIsDiff() {
// The pane is holding something other than the panel's diff: a message, or the
// hint shown for a merge conflict that has to be resolved by picking a side,
// which explains itself with a diff of what the sides did. Whatever is in there
// is not ours to act on, so this needs no content to answer either.
return false, true
}
if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff {
// Under a panel that shows no diff there is nothing to select whatever the pane
// ends up holding, so this needs no content to answer. Answering it now matters,
// because a render may never reach an end. The rest of a long commit log is read
// only as far as the user scrolls, and until then the pane would go on showing
// the selection it was left with under the panel before.
return false, true
}
if !contentIsComplete && mainContext.HasSelectableContent() {
// This render has already found something to select, and its content only grows
// from here, so there is nothing to ask again — nor to read the diff for. An
// answer the render before it gave has been dropped by now (see
// dropAnAnswerAboutAnotherRender), so this really is about the content in hand.
return true, true
}
hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view)
// One change line among those read settles it. Finding none in a render that is
// still going may only mean the changes are in the part still to come. A commit's
// diff opens with a diffstat, and for a commit touching hundreds of files that runs
// well past the screenful the first paint reveals, so that answer waits.
return hasChangeLines, contentIsComplete || hasChangeLines
}
// mainContextForView returns the context of the main pane the given view is, or nil for
// any other view.
func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
switch view {
case gui.Views.Main:
return gui.State.Contexts.Normal
case gui.Views.Secondary:
return gui.State.Contexts.NormalSecondary
}
return nil
}
func (gui *Gui) setMainPanes(panes types.MainPanes) {
gui.State.MainPanes = panes
// The label for the key that focuses the main view belongs on the pane that key
// focuses, which is the secondary one while it is the only one shown.
if panes == types.SecondaryPaneOnly {
gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary)
} else {
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
}
}
// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and
// takes it off the other one, so that only the pane the key focuses wears it.
func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) {
gui.Views.Main.TitlePrefix = ""
gui.Views.Secondary.TitlePrefix = ""
view.TitlePrefix = gui.focusMainViewJumpLabel
}
// reApplySearch runs a search the view holds again over the content a render has just
+10
View File
@@ -36,6 +36,16 @@ type renderSpec struct {
// user has configured. The renderer lays its rendering out to the width of the
// view, which only the layout settles, so the task is created after it.
func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
// Ask whatever renders the diff to state, in an OSC 1717 record per line,
// which line of which file it is rendering. This lets us act on the line the
// user is pointing at even when the rendering no longer looks like a diff.
// The variable names the protocol versions we understand, and a renderer
// that doesn't understand it ignores it, so we can set it always. It has to
// be set before the plain path below, since on that path git renders the
// diff itself, and git speaks the protocol too, for its word-diff formats,
// whose markup we could not otherwise resolve.
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)
+5 -1
View File
@@ -50,7 +50,11 @@ func (self *OptionsMapMgr) renderContextOptionsMap() {
})...)
bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool {
return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled()
// A binding that describes itself as nothing has nothing to do where we are —
// that is how a command which only applies to some of a view's contents says so
// — and an empty entry in the options bar would say nothing about it.
return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled() &&
binding.GetShortDescription() != ""
})
optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo {
+1 -1
View File
@@ -227,7 +227,7 @@ M file1
}
patchBuilder := patch.NewPatchBuilder(
utils.NewDummyLog(),
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
return "", nil
},
)
+46 -9
View File
@@ -27,11 +27,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
// 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.
spec := renderSpec{view: view, cmd: cmd, width: view.InnerWidth()}
// The command lays its output out to the width of the view (git's diffstat
// graph does), and only the layout settles that, so the task is created after
// it, as a diff renderer's is. Taking the width there, on the UI thread, also
// keeps the task goroutine from reading the view's live dimensions while it
// streams output.
gui.afterLayout(func() error {
spec := renderSpec{view: view, cmd: cmd, width: view.InnerWidth()}
return gui.newTaskForRender(spec, prefix, cmdStr, gui.plainRender)
})
return gui.newTaskForRender(spec, prefix, cmdStr, gui.plainRender)
return nil
}
// plainRender runs the command as it is, with its output going straight into
@@ -87,10 +93,14 @@ func (gui *Gui) newStringTask(view *gocui.View, str string) error {
func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
manager := gui.getManager(view)
// Whatever the view was going to be put back to belonged to a re-render of its
// content; this is a message instead, so there is nothing to put back.
manager.DropRestoreForNextTask()
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -104,11 +114,15 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX int, originY int) error {
manager := gui.getManager(view)
// Whatever the view was going to be put back to belonged to a re-render of its
// content; this is a message instead, so there is nothing to put back.
manager.DropRestoreForNextTask()
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -122,11 +136,15 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) error {
manager := gui.getManager(view)
// Whatever the view was going to be put back to belonged to a re-render of its
// content; this is a message instead, so there is nothing to put back.
manager.DropRestoreForNextTask()
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -154,10 +172,20 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
func() {
// As the task reads more lines, the only thing that changes is the
// view's content (and its scrollbar); the window layout doesn't. So a
// content-only render is enough, and it's much cheaper than a full
// layout-and-redraw on every read - which matters a lot when reading
// a long diff, where reads happen repeatedly as the user scrolls.
gui.renderContentOnly()
// content-only render is enough — it skips the layout pass and redraws
// only the cells that differ — and it's much cheaper than a full
// layout-and-redraw on every read, which matters a lot when reading a
// long diff, where reads happen repeatedly as the user scrolls.
//
// What this draws is more of the content than the pane held a moment
// ago, so it is also where what is drawn over that content is worked
// out again. The screenful the first paint reveals may not be enough
// to say whether there is anything to select, and for a diff that
// opens with a long diffstat it isn't.
gui.c.OnUIThreadContentOnly(func() error {
gui.updateDiffSelectionVisibility(view, false)
return nil
})
},
func() {
// The content is fully loaded now, so let the scrollbar track it
@@ -174,13 +202,22 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.SetOrigin(0, newOriginY)
}
gui.updateDiffSelectionVisibility(view, true)
gui.clampDiffSelectionToContent(view)
gui.reApplySearch(view)
},
func() {
view.SetOrigin(0, 0)
},
view.BeginOffscreenRender,
view.SwapInOffscreenRender,
func() {
view.SwapInOffscreenRender()
// The content the pane is being given is on display from here on, so
// what is drawn over it is settled against that content rather than
// against the render before it.
gui.updateDiffSelectionVisibility(view, false)
},
func() gocui.Task {
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not
+6 -2
View File
@@ -72,6 +72,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, making one if the view has
// never rendered anything, for saying something about a render still to come
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
ReadLinesToFillView(view *gocui.View)
@@ -461,8 +465,8 @@ type IRepoStateAccessor interface {
SetScreenMode(ScreenMode)
InSearchPrompt() bool
GetSearchState() *SearchState
SetSplitMainPanel(bool)
GetSplitMainPanel() bool
SetMainPanes(MainPanes)
GetMainPanes() MainPanes
GetMergeOrRebaseStartedInLazygit() bool
SetMergeOrRebaseStartedInLazygit(bool)
}
+90 -6
View File
@@ -78,6 +78,7 @@ type IBaseContext interface {
// true if the context holds something for a selection to sit on. Contexts that
// don't show a selection at all say false, and so do lists with nothing in them.
HasSelectableContent() bool
SetHasSelectableContent(bool)
// the total height of the content that the view is currently showing
TotalContentHeight() int
@@ -102,12 +103,13 @@ 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)
// 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)
// Likewise for the focused main view, which acts on the diff of whichever panel
// is beneath it and so has to reach that panel's controller. nil for a panel
// that shows no diff.
AddFocusedMainViewDiffSource(FocusedMainViewDiffSource)
AddOnRenderToMainFn(func())
AddOnFocusFn(func(OnFocusOpts))
@@ -180,6 +182,85 @@ 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. The focused main
// view shows a selection only for a context that implements this: a selection is
// only meaningful where there are diff lines to act on (edit one, copy some, jump
// by hunk or file). The returned type additionally classifies what acting on that
// selection means.
type DiffMainViewContext interface {
Context
GetDiffMainViewType() DiffMainViewType
}
// DiffMainViewType classifies what the focused main view's diff belongs to, which
// decides what acting on a selection in it means.
type DiffMainViewType int
const (
// DiffMainViewTypeNone: the main view holds no diff, so there is nothing to
// select. A side panel that doesn't implement DiffMainViewContext counts as
// this; no panel returns it itself.
DiffMainViewTypeNone DiffMainViewType = iota
// DiffMainViewTypeStaging: the diff is the working tree's, so the selection can
// be staged or unstaged (the files panel).
DiffMainViewTypeStaging
// DiffMainViewTypePatchBuilding: the diff belongs to a commit, so the selection
// can be taken into a custom patch (the commit files / commits / sub-commits /
// reflog / stash panels).
DiffMainViewTypePatchBuilding
)
// DiffPaneContext is one of the two panes the main section can show, as the thing
// that holds a diff with a selection in it. The panels that act on such a selection
// are handed the pane it was made in, and speak to it through this.
type DiffPaneContext interface {
Context
DiffSelectState() *DiffSelectState
}
// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it
// renders into the focused main view: the diff of the given files as git writes it,
// with no colour and no diff renderer in the way. What the main view shows is that
// same diff after a renderer has had it, which may have restructured, reordered or
// dropped parts of it — so anything that needs the diff itself, rather than a picture
// of it, asks the panel that produced it.
//
// paths are repo-relative, and are asked for rather than assumed so that a few lines
// of a commit's diff can be had without fetching the whole thing. pane says which of
// the two main panes is asking, since a panel can show a different diff in each — the
// files panel shows the unstaged changes in one and the staged ones in the other.
type FocusedMainViewDiffSource interface {
PlainDiff(pane DiffPaneContext, paths []string) string
}
// FocusedMainViewActions describes what a side panel does when the user acts on a
// selection of diff lines in the focused main view. The main view owns the selection
// and the keys; what acting on it means is the panel's business, e.g. the working
// tree panel stages and unstages.
//
// It extends the diff source rather than standing beside it, because acting on a
// selection needs the diff behind the rendering just as reading it does; a panel that
// implements only the source offers a diff to read and copy but nothing to do to it.
type FocusedMainViewActions interface {
FocusedMainViewDiffSource
// PrimaryAction acts on the diff lines in the inclusive buffer-line range that the
// current selection in the given pane covers (see View.SelectedBufferLineRange): a
// single line, a range, or a hunk. The panel re-renders the diff itself, being the
// one that knows what it did to it.
PrimaryAction(pane DiffPaneContext, firstBufferLine int, lastBufferLine int) error
// DiscardSelection takes the selected diff lines back out of whatever they are part
// of: the working tree for the files panel.
DiscardSelection(pane DiffPaneContext, firstBufferLine int, lastBufferLine int) error
}
type IListContext interface {
Context
@@ -276,9 +357,10 @@ 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 hand out the diff behind what your
// panel renders into the focused main view, for the commands that act on a
// selection in it. nil for a controller whose panel shows no diff.
GetFocusedMainViewDiffSource() FocusedMainViewDiffSource
}
type IController interface {
@@ -339,6 +421,8 @@ type IContextMgr interface {
CurrentSide() Context
CurrentPopup() []Context
NextInStack(context Context) Context
IsInStack(context Context) bool
UpdateSelectionHighlights()
IsCurrent(c Context) bool
IsCurrentOrParent(c Context) bool
ForEach(func(Context))
+50
View File
@@ -0,0 +1,50 @@
package types
// DiffLineType classifies a row of a rendered diff.
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.
DiffLineOther
)
// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch
// it was rendered from: which file the row belongs to, what kind of row it is,
// and where the line sits in the old and new versions of that file. This lets us
// act on the line the user is pointing at in a diff view: stage it, open it in an
// editor, keep the cursor on it across a re-render. The rendered text alone tells
// us none of that.
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 version of the file. Set for all
// content lines (for a deletion it is the position the deletion sits at) and
// for hunk headers (the first line of the hunk they head).
NewLine int
// OldLine is the line's position in the old version of the file. Set only
// for deletions, which are the only rows that need it: two consecutive
// deletions share a new-file position and differ only here.
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: those are the
// rows a patch is built from, and the rows navigation moves between.
func (self DiffLineInfo) IsChange() bool {
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
}
// IsContent reports whether the row is a line of the file itself — a change or a
// context line — as opposed to a header or a marker. Those are the rows that have a
// position in the file, and so can be looked for in another rendering of the same
// diff, or in the diff itself.
func (self DiffLineInfo) IsContent() bool {
return self.IsChange() || self.Type == DiffLineContext
}
+28
View File
@@ -0,0 +1,28 @@
package types
// 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 range-select start), so only the mode lives here. It's on the context rather
// than on the controller because the controller that drives the selection, the
// controller that establishes it on focus, and the pane-toggle that seeds it on the
// other pane all reach the pane through its context.
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 hunk mode was turned on by the user rather than being the configured
// default. This decides whether escape leaves hunk mode or leaves the view.
UserEnabledHunkMode bool
}
+77
View File
@@ -2,6 +2,8 @@ package types
import (
"os/exec"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
)
type MainContextPair struct {
@@ -13,6 +15,19 @@ func NewMainContextPair(main Context, secondary Context) MainContextPair {
return MainContextPair{Main: main, Secondary: secondary}
}
// MainPanes says which of the two panes of the main section are shown. Most content
// takes the main pane alone; content with two sides to it — the working tree's
// unstaged and staged changes, a commit's diff and the patch built from it — takes
// both; and content whose only side is the second one takes the secondary pane alone,
// so that it has the whole section rather than sitting under an empty pane.
type MainPanes int
const (
MainPaneOnly MainPanes = iota
BothMainPanes
SecondaryPaneOnly
)
type MainViewPairs struct {
Normal MainContextPair
MergeConflicts MainContextPair
@@ -25,6 +40,11 @@ type ViewUpdateOpts struct {
SubTitle string
Task UpdateTask
// NothingToActOn marks a pane that is being shown only because the layout is
// configured to always split the diff: its side of the file holds nothing, so it
// is not a pane to leave the focus in.
NothingToActOn bool
}
type RefreshMainOpts struct {
@@ -49,6 +69,9 @@ func NewRenderStringTask(str string) *RenderStringTask {
type RenderStringWithoutScrollTask struct {
Str string
// contentIsDiff marks a string that is a panel's own diff; see ContentIsDiff.
contentIsDiff bool
}
func (t *RenderStringWithoutScrollTask) IsUpdateTask() {}
@@ -57,6 +80,16 @@ func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask
return &RenderStringWithoutScrollTask{Str: str}
}
// NewMainViewDiffStringTask returns the task for rendering a diff we hold as text
// rather than as a command to run — the custom patch being built, which we assemble
// ourselves. The view stays where it is, the patch being rendered again on every
// change to it.
func NewMainViewDiffStringTask(str string) UpdateTask {
task := NewRenderStringWithoutScrollTask(str)
task.contentIsDiff = true
return task
}
type RenderStringWithScrollTask struct {
Str string
OriginX int
@@ -72,6 +105,9 @@ func NewRenderStringWithScrollTask(str string, originX int, originY int) *Render
type RunCommandTask struct {
Cmd *exec.Cmd
Prefix string
// contentIsDiff marks output that is a panel's own diff; see ContentIsDiff.
contentIsDiff bool
}
func (t *RunCommandTask) IsUpdateTask() {}
@@ -87,6 +123,9 @@ func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask {
type RunDiffRendererTask struct {
Cmd *exec.Cmd
Prefix string
// contentIsDiff marks output that is a panel's own diff; see ContentIsDiff.
contentIsDiff bool
}
func (t *RunDiffRendererTask) IsUpdateTask() {}
@@ -98,3 +137,41 @@ func NewRunDiffRendererTask(cmd *exec.Cmd) *RunDiffRendererTask {
func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRendererTask {
return &RunDiffRendererTask{Cmd: cmd, Prefix: prefix}
}
// NewMainViewDiffTask returns the task for rendering a diff into a main view. A diff
// normally goes through the diff renderer, however the render arranges to feed it. A
// diff we are producing with git itself, because the renderer's version of it couldn't
// be acted on, has to keep the renderer out, so it runs as a plain command instead.
//
// The task it returns is the one that says its output is a diff, so a pane rendering
// it can be pointed at (see ContentIsDiff).
func NewMainViewDiffTask(cmd *exec.Cmd, mode git_commands.DiffMode) UpdateTask {
return NewMainViewDiffTaskWithPrefix(cmd, "", mode)
}
func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_commands.DiffMode) UpdateTask {
if mode == git_commands.DiffModeRaw {
task := NewRunCommandTaskWithPrefix(cmd, prefix)
task.contentIsDiff = true
return task
}
task := NewRunDiffRendererTaskWithPrefix(cmd, prefix)
task.contentIsDiff = true
return task
}
// ContentIsDiff reports whether the given render fills a main pane with the diff a
// panel offers there, as opposed to a message, a commit log, or a diff that is part of
// an explanation. A selection means the lines of the panel's diff, so it is only over
// such a render that there is anything to point at.
func ContentIsDiff(task UpdateTask) bool {
switch task := task.(type) {
case *RenderStringWithoutScrollTask:
return task.contentIsDiff
case *RunCommandTask:
return task.contentIsDiff
case *RunDiffRendererTask:
return task.contentIsDiff
}
return false
}
+1 -9
View File
@@ -133,14 +133,6 @@ func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// renderContentOnly triggers a re-render that skips the layout pass and only
// redraws the views whose content changed (relying on tcell's cell-level dirty
// tracking to emit just the cells that actually differ). Use it when only a
// view's content changed, not the window layout.
func (gui *Gui) renderContentOnly() {
gui.c.OnUIThreadContentOnly(func() error { return nil })
}
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
@@ -154,7 +146,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
// The render may have given the context its first item, or taken its last one
// away, which decides whether its view draws a selection at all.
gui.State.ContextMgr.updateSelectionHighlights()
gui.State.ContextMgr.UpdateSelectionHighlights()
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
+12 -3
View File
@@ -168,6 +168,15 @@ func (gui *Gui) createAllViews() error {
gui.Views.Snake.FgColor = gocui.ColorGreen
// The main views show diffs, whose own colors say what each line is: which side of
// the diff it's on, and often its syntax highlighting too. A selection painted
// across the whole line takes those colors over, which for a whole selected hunk
// leaves one unreadable block; so mark the selection with a narrow bar at the left
// edge instead, and leave the rest of the line to the diff. Two columns, enough to
// read as a marker rather than as an artefact.
gui.Views.Main.SelectedLineColorWidth = 2
gui.Views.Secondary.SelectedLineColorWidth = 2
return nil
}
@@ -274,11 +283,11 @@ func (gui *Gui) configureViewProperties() {
}
}
gui.focusMainViewJumpLabel = ""
if gui.c.UserConfig().Gui.ShowPanelJumps {
gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
} else {
gui.Views.Main.TitlePrefix = ""
gui.focusMainViewJumpLabel = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
}
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
// Index the tab strips by view so we can both set them on views that are
// part of a multi-tab panel and clear them on views that no longer are
+10
View File
@@ -310,6 +310,7 @@ type TranslationSet struct {
DiscardSelectionTooltip string
ToggleSelectHunk string
SelectHunk string
NothingToSelectInDiff string
SelectLineByLine string
ToggleSelectHunkTooltip string
HunkStagingHint string
@@ -410,6 +411,8 @@ type TranslationSet struct {
AskQuestion string
PrevHunk string
NextHunk string
PrevFileInDiff string
NextFileInDiff string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -733,6 +736,8 @@ type TranslationSet struct {
CopyPathToClipboard string
CommitPrefixPatternError string
CopySelectedTextToClipboard string
SelectedDiffLinesCopiedToast string
SelectionNotFoundInDiffToast string
NoFilesStagedTitle string
NoFilesStagedPrompt string
BranchNotFoundTitle string
@@ -1493,6 +1498,7 @@ func EnglishTranslationSet() *TranslationSet {
ToggleRangeSelect: "Toggle range select",
DismissRangeSelect: "Dismiss range select",
ToggleSelectHunk: "Toggle hunk selection",
NothingToSelectInDiff: "There is nothing to select here",
SelectHunk: "Select hunks",
SelectLineByLine: "Select line-by-line",
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
@@ -1598,6 +1604,8 @@ func EnglishTranslationSet() *TranslationSet {
AskQuestion: "Ask Question",
PrevHunk: "Go to previous hunk",
NextHunk: "Go to next hunk",
PrevFileInDiff: "Go to previous file",
NextFileInDiff: "Go to next file",
PrevConflict: "Previous conflict",
NextConflict: "Next conflict",
SelectPrevHunk: "Previous hunk",
@@ -1919,6 +1927,8 @@ func EnglishTranslationSet() *TranslationSet {
CopyTagToClipboard: "Copy tag to clipboard",
CopyPathToClipboard: "Copy path to clipboard",
CopySelectedTextToClipboard: "Copy selected text to clipboard",
SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard",
SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff",
CommitPrefixPatternError: "Error in commitPrefix pattern",
NoFilesStagedTitle: "No files staged",
NoFilesStagedPrompt: "You have not staged any files. Commit all files?",
+77
View File
@@ -88,6 +88,17 @@ func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver {
return self
}
// TitlePrefix asserts on the label a view wears in front of its title, which is the
// key that jumps to it.
func (self *ViewDriver) TitlePrefix(expected *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().TitlePrefix
return expected.context(fmt.Sprintf("%s title prefix", self.context)).test(actual)
})
return self
}
func (self *ViewDriver) Clear() *ViewDriver {
// clearing multiple times in case there's multiple lines
// (the clear button only clears a single line at a time)
@@ -141,6 +152,57 @@ func (self *ViewDriver) VisibleLines(matchers ...*TextMatcher) *ViewDriver {
return self.assertLines(originY, matchers...)
}
// ContainsViewLines asserts that somewhere in the view there are consecutive view lines
// matching the given matchers. View lines are the rows the view draws its content in,
// so a line too long for the view is as many view lines as the view wraps it into.
func (self *ViewDriver) ContainsViewLines(matchers ...*TextMatcher) *ViewDriver {
self.validateMatchersPassed(matchers)
self.t.assertWithRetries(func() (bool, string) {
lines := self.getView().ViewBufferLines()
for i := 0; i+len(matchers) <= len(lines); i++ {
matches := true
for j, matcher := range matchers {
if ok, _ := matcher.test(lines[i+j]); !ok {
matches = false
break
}
}
if matches {
return true, ""
}
}
return false, fmt.Sprintf(
"%s: Expected the following view lines to be contained:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----",
self.context,
expectedContentFromMatchers(matchers),
strings.Join(lines, "\n"),
)
})
return self
}
// Asserts on the line the view shows at the top of its viewport, i.e. on where the
// view is scrolled to. It is a view line, so a wrapped line above it doesn't throw the
// count off.
func (self *ViewDriver) TopVisibleLine(matcher *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
view := self.getView()
lines := view.ViewBufferLines()
originY := view.OriginY()
if originY >= len(lines) {
return false, fmt.Sprintf("%s: the view is scrolled to line %d, but it has only %d lines",
self.context, originY, len(lines))
}
return matcher.context(fmt.Sprintf("%s top visible line", self.context)).test(lines[originY])
})
return self
}
// asserts that somewhere in the view there are consecutive lines matching the given matchers.
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
self.validateMatchersPassed(matchers)
@@ -249,6 +311,21 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver {
return self
}
// SelectedViewLineRange asserts which view lines the selection covers. View lines
// count the wrapped segments a line is drawn as, so this can say whether a selection
// covers a wrapped line to its end; SelectedLines, which reports the lines of the
// content, cannot.
func (self *ViewDriver) SelectedViewLineRange(first int, last int) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actualFirst, actualLast := self.getSelectedRange()
return actualFirst == first && actualLast == last,
fmt.Sprintf("%s: Expected view lines %d-%d to be selected, but %d-%d were.",
self.context, first, last, actualFirst, actualLast)
})
return self
}
func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) {
if len(matchers) < 1 {
self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()")
@@ -54,6 +54,6 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{
Contains("M file").IsSelected(),
)
t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
t.Views().Secondary().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
},
})
@@ -73,6 +73,7 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
SelectedLine(Contains("file1"))
t.Views().Main().Content(Contains("+second line"))
// The patch was applied to the index, so the file's changes are all staged.
t.Views().Secondary().Content(Contains("+second line"))
},
})
@@ -0,0 +1,50 @@
package diff
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiffRendererMetadata = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A diff renderer is told that we understand the OSC 1717 metadata protocol, and the records it emits don't show up in the rendered diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// A fake conforming renderer: it announces the protocol with a
// version-only record, reports the protocol versions it was offered, and
// then passes the diff through with a per-line record in front of every
// line.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `printf '\033]1717;1\007'; ` +
`printf 'OFFERED:%s\n' "$OSC1717"; ` +
`while IFS= read -r line; do printf '\033]1717;1;c;1;;file1\007%s\n' "$line"; done`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("one").IsSelected(),
)
t.Views().Main().
// The renderer was offered the protocol version we understand.
Content(Contains("OFFERED:V1")).
// Its records are escape sequences, so none of them reaches the
// screen; the diff reads exactly as the renderer wrote it.
ContainsLines(
Equals("diff --git a/file1 b/file1"),
Contains("new file mode"),
Contains("index "),
Equals("--- /dev/null"),
Equals("+++ b/file1"),
Equals("@@ -0,0 +1,2 @@"),
Equals("+one"),
Equals("+two"),
)
},
})
@@ -0,0 +1,55 @@
package diff
import (
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StatFollowsAScreenModeChange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A commit's diffstat is laid out again to the width the view has after a screen mode change narrows it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// git's own diff, so that the render runs as a plain command rather
// than through a renderer.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Type: "rawGit"},
}
},
SetupRepo: func(shell *Shell) {
// Enough added lines that git scales the graph to the width it has,
// rather than drawing one mark per line.
lines := make([]string, 200)
for i := range lines {
lines[i] = "line " + strconv.Itoa(i)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("add file1")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("add file1").IsSelected(),
)
t.Views().Main().
ContainsViewLines(
Contains("file1 | 200"),
Contains("1 file changed"),
)
// The focused panel takes half the screen, which leaves the main view
// narrower than it was.
t.Views().Commits().Press(keys.Universal.NextScreenMode)
t.Views().Main().
ContainsViewLines(
Contains("file1 | 200"),
Contains("1 file changed"),
)
},
})
@@ -32,7 +32,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" R dir/nested/file3 → file3"),
)
t.Views().Main().ContainsLines(
t.Views().Secondary().ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
@@ -51,7 +51,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
SelectNextItem().
SelectedLine(Equals(" ▼ dir"))
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
@@ -75,7 +75,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" R file1 → file1"),
)
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
@@ -0,0 +1,61 @@
package file
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PaneShownAgainStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A pane that was emptied while it wasn't shown starts at the top when it comes back, rather than where it was left",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("file2", "one\n")
shell.Commit("one")
// More staged changes in file1 than fit in the pane they are shown in, so that
// there is a position in it to lose, plus an unstaged change to give the file a
// second pane.
for i := range lines {
lines[i] = strings.ToUpper(lines[i])
}
shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.UpdateFile("file1", strings.Join(lines, "\n")+"unstaged\n")
shell.UpdateFile("file2", "two\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
NavigateToLine(Contains("file1"))
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
// A file with nothing staged leaves that pane with nothing to show, so it goes
// away and is emptied.
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Secondary().IsInvisible()
t.Views().Files().NavigateToLine(Contains("file1"))
t.Views().Secondary().
IsVisible().
Content(Contains("+LINE40")).
OriginY(0)
},
})
@@ -0,0 +1,85 @@
package file
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// changedLines is a file's worth of numbered lines, prefixed so that each file's diff
// can be told from the other's on screen.
func changedLines(prefix string) string {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("%s%02d", prefix, i+1)
}
return strings.Join(lines, "\n") + "\n"
}
var PaneTakingOverStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A pane taking the main section over shows its diff from the top, rather than at the offset it was left at",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", changedLines("one"))
shell.CreateFileAndAdd("file2", changedLines("two"))
shell.Commit("one")
// One file's changes are unstaged and the other's are staged, so each is shown
// in a pane of its own — and selecting one after the other hands the section
// from one pane to the other. Both diffs are longer than the section, so either
// pane can be scrolled.
shell.UpdateFile("file1", changedLines("ONE"))
shell.UpdateFileAndAdd("file2", changedLines("TWO"))
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
NavigateToLine(Contains("file1"))
t.Views().Secondary().IsInvisible()
t.Views().Main().
IsVisible().
Title(Equals("Unstaged changes")).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Main().IsInvisible()
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
Content(Contains("+TWO40")).
OriginY(0).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
// Back to the pane that was left scrolled: what it is given is a diff the user
// hasn't seen there, so it starts at the top like any other.
t.Views().Files().NavigateToLine(Contains("file1"))
t.Views().Secondary().IsInvisible()
t.Views().Main().
IsVisible().
Title(Equals("Unstaged changes")).
Content(Contains("+ONE40")).
OriginY(0)
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Main().IsInvisible()
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
Content(Contains("+TWO40")).
OriginY(0)
},
})

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