Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 164e65ceee Add a menu of the diff's files to jump to one directly
n and N step through the files of a multi-file diff one at a time, which
is a long way to the far end of a commit touching a hundred files.

Bind ctrl+g to a menu of the diff's files, in the order the diff shows
them and by the paths the repo knows them by; picking one goes to where
its diff begins, exactly where stepping to it with n would have left
you. The menu filters as you type, so the file you have in mind is a few
characters away however many the commit touches.

The key works in the panel the diff belongs to as well as in the diff
itself. A commit is read from the commits panel, so being able to jump
from there saves focusing the diff and leaving it again for the next
commit; the diff scrolls to the file and the focus stays in the panel.
It is ctrl+g rather than f because a key that works in every panel has
to be free in all of them, and f is fetch in the files panel and fixup
in the commits panel.

The command applies only while the main view is showing a diff, and is
left out of the keybindings menu where it isn't: over a branch's commit
log, or while a conflicted file has given the main section over to the
merge conflicts view.

The diff is read to the end before the menu is built, as searching it
does: a file below the part that has been read is in neither the list
nor the view. Each item names its file rather than the row that file
begins at, so that a diff re-rendered while the menu is up is jumped
into at the row the file begins at now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 61382b8d37 Move where a jump lands into the diff line helper
Where a focus or a click puts the focused main view's selection is
worked out in the diff line helper; where a jump puts it is worked out
on the main view controller, though it is the same question about the
same pane. A jump asked for from anywhere else — from a panel below the
pane, or from a click on a link in the diff — has no way to reach that
answer.

Move it over. The controller keeps placeNavigationTarget as the short
way to say it for the jumps it makes itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 d2543bd7ae Ask a diff where each of its files begins
File navigation works out where the neighbouring file begins by walking
the rows itself, forwards or, more laboriously, backwards. A menu of the
diff's files needs the same rows, all of them at once.

Extract fileStarts, which answers that for the whole diff, and have
navigation pick its neighbour out of the answer. The two then agree on
where a file begins by construction, and the walk backwards over a file
goes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 2bf39e0f0c Name the file a diff row belongs to in the repo's terms
The identity of a row is reported in the repo's terms already: the pane
previewing the custom patch shows a diff of the two trees the patch was
materialized into, and the paths of those trees are mapped back to the
repo's files before anything sees them. Asking which file a row belongs
to, which file navigation does, was the one query that skipped that step
and answered with the tree's path.

Pull the mapping out of inRepoTerms so that it can be applied to a bare
path, and put filePaths through it. A menu listing the files of the diff
will want to show them by name; where a renderer states the path of each
side of a change, this also has both halves belong to one file rather
than to the two trees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 83450db146 Open the selected diff line in the branch's pull request
Reading a change in lazygit and saying something about it on GitHub
means finding the line again in the browser: open the pull request, find
the commit, find the file, scroll to the line. The line is already under
the cursor here.

Bind G in the focused main view, the key the commits panel opens the
pull request with, to open it at the line the selection is on. The URL
names the commits whose diff is on screen, so that the line numbers of
the diff are the ones the page shows, the file by the SHA-256 of its
repo-relative path, and the line by the side of the diff it is on: R for
the new version of the file, L for the old one, where a deleted line is.
GitHub documents none of that; the form was read off the URLs its own
pages carry.

One commit is named by its hash. A range of them is named by the commit
the range starts after and the commit it ends at, the form the chooser
above a pull request's files uses. The commit a range starts after is
the parent of its oldest commit; where the range starts where the pull
request itself does, that parent is none of the pull request's own
commits, and the keyword BASE stands for it.

Which branch's pull request that is depends on the panel beneath: the
checked-out branch below the commits panel, the branch drilled into
below the sub-commits panel, and whichever of those the commit files
panel was entered from. Panels showing a diff that no pull request has a
view of don't answer, and the command isn't offered over their diffs at
all.

Neither is it offered over a diff that is not the commit's own, where
the line numbers on screen are not the ones the page shows: a diff
against another ref in diffing mode, and the custom patch, whose lines
sit at the numbers the patch gives them.

A pull request holds only the commits of its branch that are pushed, and
its pages say they can't find any other commit. So the command refuses
where a commit of the diff is not one of the pull request's. Amend a
commit in the middle of the branch, and the diffs of the commits below
it still open; the ones above it sit on hashes the remote doesn't have.
A commit from before the branch, in a main branch already, is refused
too.

Only GitHub pull requests are known, since that is where the pull
request data comes from. The whole path can't be exercised headlessly:
no pull request reaches the model without a GitHub token, so the test
covers where the command is offered and the three reasons it refuses.
The URL is unit-tested instead, both the anchor of a line and the way
the commits are named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 8842f17186 Ask for the panel beneath the focused main view in one place
Two questions the pane answers from the panel beneath it each reach for
it themselves, guard included. Opening a line in a pull request needs it
twice more, for the branch and for the commit.

Extract sidePanelBeneath, which is also where the guard against asking
for the panel beneath an off-stack pane now belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 3603883e83 Name a diff line's file in the repo's terms in one place
A diff line carries the absolute path of its file, and both panels
acting on such a line turn it into the repo-relative one git speaks
themselves. Opening a line in a pull request needs that path too, to
name the file to GitHub by it.

Extract repoRelativePath, and have both panels use it. The files panel
gains the check for a path outside the repo that the other one had; a
path it used to pass on matches no file of the working tree either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 c0efca5ae6 Ask one place whether a branch has a pull request
Two panels offer a branch's pull request today, and each looks it up in
the model's map itself and builds the same disabled reason from the same
string. The focused main view is about to offer a line of the diff in
that pull request, which would make three.

Put the lookup and the disabled reason on the host helper, beside the
pull request URL it already builds, and have both panels ask it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan Haller 020991cab8 Acknowledge editor clicks in the diff
Non-suspending graphical editors can take a moment to reach the
foreground, leaving a modified click with no visible response. Briefly
reverse the clicked row's selection bar after launching the editor so
the registered action is apparent.

Arm the flash only for a resolved diff row, keep newer clicks safe from
stale timers, and rely on suspension to clear the transient state before
terminal editors take over.
2026-09-27 18:26:56 +02:00
Stefan Haller c3ff41f726 Give views a transient line flash
Actions that hand control to another application need visible
acknowledgement without moving a view's cursor or replacing renderer
colors. Reverse the narrow selection bar independently of selection
state, and clear transient flashes whenever the terminal UI suspends.
2026-09-27 18:26:56 +02:00
Stefan Haller 25d5e782d8 Open a clicked diff line in the editor
Clickable renderer gutters are small and unavailable on some diff rows.
Make the whole row an editor target without changing focus or selection,
including while a popup is focused.

Use both Alt and Shift because terminal mouse protocols do not deliver
either modifier consistently across Ghostty, iTerm2, and VS Code.
2026-09-27 18:26:56 +02:00
Stefan Haller 7f238de1ea Keep mouse gesture modifiers stable
Bindings match modifiers exactly, so a modified press must not turn into
an unmodified drag or release halfway through the gesture. Capture the
press-time modifiers once and carry them until the button is released.

This also makes unbound modified clicks no-ops instead of silently
invoking plain-click behavior.
2026-09-27 18:26:56 +02:00
Stefan Haller 64ebdf4b79 Share diff-line editing with mouse actions
The selected-line keybinding and a modified click need the same path
from a rendered diff row to the editor. Give that operation an explicit
view-line argument before adding the mouse gesture.
2026-09-27 18:26:56 +02:00
Stefan Haller 39de93797b Let mouse bindings work behind focused popups
Mouse events on views behind a popup are normally swallowed before their
bindings can run. Add an explicit early-dispatch opt-in for actions that
should remain live there, matching the phase where hyperlink clicks
already run.
2026-09-27 18:26:56 +02:00
Stefan Haller 79ab37ff35 Separate redraws from view-line invalidation
A view can need repainting even when its cached wrapping is still valid.
Track that state independently so content-only flushes do not overload
tainted, whose only job is to request a viewLines rebuild.
2026-09-27 18:26:56 +02:00
Stefan Haller a4ce26eb2d Remove unused per-line highlighting
View.SetHighlight has no production callers.
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 acba0641fc Cleanup: remove error return value from Gui.SetRune, Gui.draw() et al
These always returned nil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan Haller bcd50fb938 Rename ctrl+o description to "Copy selected diff lines to clipboard"
In the staging panel it used to copy the selected text verbatim, but in
the focused main view it copies the diff lines (that's also what the
toast says), and now that the staging panel is gone, we can change the
text to make it more specific.
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot ab380065cd Remove the last staging-panel language from tests
Generic range assertions and migrated commit tests should describe the views
they still exercise. Drop stale explorer wording so failures and test intent
no longer point contributors at UI that does not exist.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 94e595b0d8 Document line actions in the focused diff
Users no longer enter separate staging or patch-building panels. Describe file
entry, pane switching, staging, and custom patch construction where those
actions now happen, and remove the deleted package from the developer guide.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 5da90439ee Let wrapLinesInDiffView govern the two main panes
Working in a diff used to mean the staging view, and this option said
whether the long lines there were wrapped. The main view does that work
now, and the option has had nothing to govern since the staging view went
away. Both panes wrap whatever they are given.

Make the option their wrap setting instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot a15a80bdd6 Name diff options after the view that now uses them
Hunk defaults and wrapping no longer belong to a staging panel. Rename both
public keys with automatic migration, and remove panel-era English strings so
config, UI, schema, and generated docs describe the surviving interface.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 1838671f32 Allow context changes while building a custom patch
The focused diff addresses patch lines by identity, so changing how much
context is rendered cannot invalidate an active patch. Remove the
explorer era refusal and prove that another line can still be toggled
correctly after the rerender.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 8ddf8bc7c5 Remove the staging and patch-building panel shells
Nothing routes to the explorer contexts after line actions and file entry
moved into the normal main pair. Delete their contexts, views, main-pair
wiring, discovery API, test drivers, and selection-state package so the
surviving diff view is the only implementation.

Their names go from the contexts a custom command may bind to as well, so
that a config still naming one is reported as the config error it now is
rather than taken as a context we simply failed to find.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot aada2fb6d5 Remove the explorer behavior behind the retired panels
Focused main views now own selection, staging, patch construction, copying,
and context-size rerenders. Remove the parallel controller/helper stack and
its refresh scopes, while retaining custom-patch reset as mode behavior.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 28d5bdbc08 Move the diff-copy helper out of the explorer controller
The next commit deletes the controller this lives in, while the helper
itself is still wanted: it is what makes a copied selection paste
straight into code. Move it first, so that the deletion is a deletion.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 7857f9995f Open file diffs in the focused main view
Enter and double-click should take users to the diff where line actions now
live, whether the file belongs to the working tree or a commit. Share the
existing focus, raw-fallback, and selection setup while retaining directory,
submodule, and conflict-specific behavior.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 26abfa6c82 Move the last explorer workflows onto the focused diff
Historical file removal and the staging/custom-patch demos still entered
the old panels even though their line actions already exist in the main
diff. Preserve their prompts, timing, and downstream results while changing
only the interaction surface.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 13c2a24617 Drop test steps owned by the retiring explorers
The generic filter and range-selection suites already exercise their state
machines on surviving views, while focused-diff tests own range selection
and drag autoscroll. Remove only the repetitions that enter an explorer,
along with the staging-panel screen-mode test whose UI no longer survives.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan Haller c8b99748f3 Migrate range select tests to focused main view 2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 1e45269748 Exercise commit and stash flows through the focused diff
Line staging remains part of several broader workflows: committing staged and
unstaged changes, bypassing hooks, and stashing only the staged part of a
file. Drive those selections through the main diff pair while preserving
their commit, focus, and exact stash-content assertions.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 75a47b1b4c Build mixed custom patches from the focused diff
The broad regression for combining a whole file, a hunk, a range, and
individual lines must survive the patch-building panel. Keep the side panel
for the explicit whole-file operation and drive every content selection
through the commit's focused diff.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 99588e973b Keep partial rename patches on the focused diff
Selecting a rename's content changes must remain distinct from selecting
the file operation itself. Build and remove that partial patch through
the focused commit diff, and keep asserting that the rename stays in its
source commit after the content change is taken out.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot d185309368 Manage patch lifetime from the focused diff
Removing a whole or partial patch from its source commit, and replacing
a patch when the user selects another commit, must not depend on
entering a patch-building panel. Keep those state transitions covered
through the diff that now owns patch selection.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 5fbbaff982 Build applied patches from the focused diff
Applying and reverse-applying custom patches must keep their staging,
dirty-worktree, and conflict behavior after the patch-building panel is
removed. Select the source lines in commit diffs while retaining every
assertion about the resulting working tree and conflict resolution.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot bbc2e56747 Keep historical diff actions off the explorers
Editing a line whose working-tree position shifted, discarding part of
an added file, and giving up a patch by escaping are all behavior worth
retaining. Drive them through the focused commit diff so their coverage
no longer depends on the patch-building panel.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 3b5bfe4529 Move partial-patch destination tests onto the commit diff
Line-level patch moves must keep their behavior for added and deleted
files, adjacent additions, stacked branches, and conflicting earlier
destinations. Select those lines directly in the focused diff so only
the intended changes move and file-level metadata stays with the source
when appropriate.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 505b464e89 Move whole-patch destination tests onto the commit diff
Moving a complete patch must preserve each file operation whether its
destination is a new commit before or after the source, or an existing
commit earlier or later in history. Build those patches as ranges over
the focused diff so the coverage survives removal of directory toggling
in the patch builder.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 6d46731e05 Keep patch-move conflict coverage on the focused diff
Conflict and dirty-worktree handling happen after a custom patch is
built, but their tests must no longer rely on either explorer to build
it. Select whole and partial patches in the commit diff while preserving
the rebase-conflict and stash-restoration assertions.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 9059d3e3b7 Move index-operation edge cases onto the focused diff
Patch movement still needs coverage for partial modifications, adjacent
additions, custom diff settings, and selections spanning files after the
patch-building panel goes away. Drive each through diff-line identities
while keeping the resulting commit and index assertions unchanged.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 2ee56c2475 Preserve whole-file operations for added and deleted files
A focused diff must replace the patch builder's whole-file toggle
without losing file creation or deletion metadata. Ordinary modifications
and renames must remain line-level selections even when every changed
content line is selected.

Ask the patch builder's canonical raw diff whether it consists of one
hunk containing only additions or only deletions and no context. Use the
whole-file operation only when the selection also covers every change.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 0c4dee5d0a Expose the missing whole-file semantics in the focused diff
The patch-building panel could move an added file as a file, while
selecting every visible change through the focused diff currently moves
only its content and leaves the empty file behind. Keep the intended
index state beside the current one so the replacement flow demonstrates
that gap before it is fixed.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot b9de40bdbc Exercise patch-move selection recovery through the diff
The commit rewrite triggered by moving a custom patch must keep the
user's place without relying on the patch-building panel. Build the
patch and leave the affected range selected in the focused diff so the
regression test follows the UI that survives this branch.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandGitHub Copilot 95bd5ef275 Keep whole-file discard coverage on the surviving diff view
Removing the staging panel must not lose the handoff that happens when a
file's final change disappears. Drive that behavior through the focused
main view so the test continues to require focus and selection to follow
the files panel onto the next diff.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandGitHub Copilot 4a90a2149b Let focused diff tests own shared staging behavior
Keeping the same staging interactions covered through the explorer would
pin the suite to the panel that this branch removes. The focused main
view now owns line, range, hunk, navigation, search, context-size, and
rapid-input coverage.

Co-Authored-By: GitHub Copilot <copilot@github.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 8835993146 Remove the hint about hunk selection being the default
The hint was raised on entering the staging panel, once, to explain that
the selection mode there had changed and how to get the old one back. That
panel is gone, and with it the moment the hint was tied to; what is left is
a string nothing prints and a flag nothing reads.

The advice it carried is not lost: the option it names is documented, and
the key that switches modes is on screen while a diff is focused.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 da57ab32e7 Take lines back out of the custom patch from the pane showing it
Space in the pane previewing the patch had nothing to act on: everything
there is in the patch already, so it can only mean taking those lines back
out, in the way that space in the staged side of the working tree's diff
only unstages.

A line of the patch can't be found by its number: the patch renumbers
whatever follows a change it leaves out, so a line of it names a line of
the commit's diff that isn't the one shown. A line is identified instead
by its position among its file's changes, counted in the diff the patch is
shown as. That is the same position it has among the changes the patch
holds for the file, since both are in the order the file has them. This
holds however the rendering came out, so a renderer that groups a hunk's
deletions before its additions, or leaves a change out altogether, no
longer matters.

The pane's rows are stated in terms of the trees the patch was
materialized into, so the paths come back to the repo's own before
anything is looked up in them. This also lets the lines there be copied
and edited, as the lines of a commit's diff can be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 aaeb44752a Materialize the custom patch so the diff renderer can show it
The pane beside a commit's diff showed the patch being built from it by
assembling the text itself. That text could not be handed to a diff
renderer the way a diff can: a stdin filter might have coped, but a tool
that diffs two files could not. Its idea of how much context to show
around a hunk was also its own rather than git's.

Materialize the patch instead: write each of its files as it is before the
patch into one tree and as it is after into another, and let git diff the
two trees. The patch becomes a diff of real files, rendered by whatever
renders the rest of them, with git's own context around it. Its lines can
then be pointed at; taking them back out of the patch will need that.

The trees are named a and b, so that with git's own prefixes suppressed
the paths read like an ordinary diff's over the repo's own paths. They are
written when the patch changes rather than when it is shown, the patch
builder counting its own versions for that, and they go away with the
patch.

A renamed file is materialized under the name the patch expects to find it
under. Where the patch carries the rename, that is the name the file had
before, so the rename comes out as a rename. git names the trees
themselves in the two rename lines, having only the two paths to go by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e4f8199049 Mark the lines of a commit's diff that are in the custom patch
A patch built from what is on screen has to show what is in it, over the
diff those lines were taken from — which may be a diff renderer's rendering
of it, whose bytes are none of ours to touch.

So the marks are drawn in a gutter over the content, from the lines the
patch holds rather than from where they were drawn last: they are worked out
again whenever a pane's content settles, which is what keeps them right
across a renderer switch, a change of context size, or a walk through the
commits, and whenever the patch itself changes.

They come and go with the focus, being what the space key in the focused
view would act on, and stay while the focus moves between the diff and the
patch previewed beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 b20c9134b9 Build a custom patch from a commit's diff, and discard lines from it
The five panels that show a commit's diff now offer on it what the patch
explorer offers on the one file it can show at a time: space takes the
selected lines into the custom patch being built, or back out of it again,
and d takes them out of the commit itself.

So a patch can be built straight from the diff already on screen, over
several files at once and without entering the commit's files first — and
from a stash entry or a reflog entry too, whose diffs the explorer could
reach but which had no patch preview of their own to show for it.

The two arrive together because they are one answer to what a commit's diff
offers, and because d needs exactly what space builds: a patch of the
selection, which the rebase then removes from the commit. Which of the two
space is doing is decided by the first selected line, as toggling a file in
the commit files panel is, and the selection then moves past the lines
dealt with — they are still in the diff, a patch leaving the commit alone,
so a second press would otherwise take them straight back out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e110536bc5 Say which change line is meant in one way
Which line of a file a diff row is, in the terms a patch is built in, is
asked in two places already — staging a selection and editing a hunk — and
is about to be asked in a third, of a commit's diff rather than the working
tree's. The rule is the same one everywhere: an addition is where it sits in
the new version of the file, a deletion where it sat in the old one.

So the row is asked for its identity and the patch package for the lines
with those identities, instead of walking the diff working the identities
out again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 4cfa44d261 Keep the diff selection when a commit is rewritten under it
Removing lines from a commit, moving a custom patch out of one, undoing
either — none of it goes through the focused main view, so a selection over
that commit's diff was left where it was, painted over a rendering of a diff
that no longer exists.

Every render now asks whether it is showing a different diff than the one on
screen, which is what a rewritten commit looks like, and puts the selection
back on the change that has taken its place — the same reveal an action in
the view does for itself. It stands down for a render of the same diff,
where a selection, perhaps a range still being made, is exactly right as it
is, and for one that something more precise is already waiting to place.

The key a render is remembered under moves ahead of anything that might
change the command's arguments, so that it says which diff is being
rendered and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 1edfbc5e80 Move the post-action reveal onto the diff-line helper
It is built entirely on that helper, and the render chokepoint that is about
to want it lives in the gui package, which cannot reach a controller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 51d5531156 Give the panels showing a commit's diff one voice about it
Five panels show a commit's diff in the focused main view — one file of it
from the commit files panel, the whole of it from the commits, sub-commits,
stash and reflog panels — and each answered for that diff itself, in the
same words.

Have them share one object instead, told only which diff it is, so that what
follows has a single place to say what can be done to a commit's diff and
needs saying once rather than five times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ed554663d0 Let the patch builder be told which lines by their identity
A patch is built in terms of where a line sits in the file's diff, which is
what the patch explorer has to hand. The main view doesn't: a row of it
resolves to a line of a file, and what index that line has depends on how
much of the diff has been read and how a diff renderer chose to lay it out.

So let the two meet at the patch builder's edge, in the identity of a change
line — its number on the side it belongs to — leaving the main view to speak
only of lines it can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ec5b2bff42 Draw a gutter of inclusion marks over a view's content
The lines of a commit's diff that are in the custom patch being built have
to be shown as such over whatever a diff renderer made of that diff, whose
bytes we can't touch — and mustn't, since everything we know about a row is
keyed by where it sits in the content.

So the marks are a decoration drawn over the content instead: a column
reserved at the left of every line, blank but for the lines that are in,
with the content shifted along past it. Nothing about the content changes
but the width it has to wrap in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 7f00059cea 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ce734da4fd 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 00d8df3408 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 23718395ad 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-09-27 18:26:55 +02:00
Stefan Haller ea62702dfb 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 a88ec1abd3 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 3cfdd0cd2d 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e4bb7e6dc6 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e855953923 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e5fa336c1a 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 985b2a2736 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 a5dfc78f82 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 1f3f29a9ab 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 a0af7275af 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 739412cd5f 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e9e5462a5a 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 c4a7978dd4 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 11e58f7900 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 7b912d2f51 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 d514cd400c 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 720097da50 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 33e53a7579 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 2c233d52d4 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 55752f8ba7 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 5f032e372a 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 636832e045 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 2236c8d8d9 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ed7ab3ac26 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 233e429221 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 02e873d1fe 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 9ec77382cc 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 20625407d6 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 acfaada238 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ec673373c4 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-09-27 18:26:55 +02:00
Stefan Haller 7ccc8653b7 Fix AGENTS.md markdown syntax
VS Code changes *italics* to _italics_ when saving, so normalize these
once.
2026-09-27 18:26:55 +02:00
Stefan Haller d71414aaa8 AGENTS.md additions 2026-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 0a8f7a8262 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 2022a58857 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 ec02df73cb 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 e583bded5d 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 c7a5276d11 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 a76fe1a2b9 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 cf227ac868 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 f84cd85180 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 831ff5c32f 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-09-27 18:26:55 +02:00
Stefan HallerandClaude Opus 5 bb5fed564b 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 54f0afbf36 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 75975c7e89 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 6a03202cae 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 bef4da435a 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 de417d8e73 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-09-27 11:32:31 +02:00
Stefan HallerandClaude Opus 5 4daa9edccd 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-09-27 11:32:31 +02:00
298 changed files with 15115 additions and 5166 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 -2
View File
@@ -135,7 +135,7 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to
### Stage individual lines
Press space on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to select the entirety of the current hunk.
Press `<enter>` on a changed file to focus its diff in the main view. Press `<space>` on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to switch to hunk selection mode. When a file has both staged and unstaged changes, use `<tab>` to move between the two diff panes; the same actions stage or unstage the selection depending on the pane.
![stage_lines](../assets/demo/stage_lines-compressed.gif)
@@ -195,7 +195,7 @@ You can create worktrees to have multiple branches going at once without the nee
You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus the patch, then `<space>` to add the comment line to our custom patch, and then `ctrl+p` to view the custom patch options; selecting to remove the patch from the current commit.
In this example we have a redundant comment that we want to remove from an old commit. We hit `<enter>` on the commit to view its files, then `<enter>` on a file to focus its diff. From there, `<space>` adds the selected comment line to the custom patch and `ctrl+p` opens the custom patch options, where we choose to remove the patch from the original commit.
Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
+13 -9
View File
@@ -72,7 +72,7 @@ gui:
# If true, do not show a warning when amending a commit.
skipAmendWarning: false
# If true, do not show a warning when discarding changes in the staging view.
# If true, do not show a warning when discarding changes from a focused diff.
skipDiscardChangeWarning: false
# If true, do not show warning when applying/popping the stash
@@ -142,14 +142,13 @@ gui:
# - 'top': split the window vertically (side panel on top, main view below)
enlargedSideViewLocation: left
# If true, wrap lines in the staging view to the width of the view. This makes
# it much easier to work with diffs that have long lines, e.g. paragraphs of
# If true, wrap lines in focused diffs to the width of the view. This makes it
# much easier to work with diffs that have long lines, e.g. paragraphs of
# markdown text.
wrapLinesInStagingView: true
wrapLinesInDiffView: true
# If true, hunk selection mode will be enabled by default when entering the
# staging view.
useHunkModeInStagingView: true
# If true, hunk selection mode will be enabled by default when focusing a diff.
useHunkModeInDiffView: true
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko'
# | 'ru' | 'pt'
@@ -698,6 +697,7 @@ keybinding:
- "4"
- "5"
focusMainView: "0"
jumpToFile: <ctrl+g>
nextMatch: "n"
prevMatch: "N"
startSearch: /
@@ -845,6 +845,8 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
@@ -935,7 +937,9 @@ It is used, for example, when pasting a commit message into the commit message p
## Configuring File Editing
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file if you invoke it from the staging panel, for example.
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
You can also open a line in your editor with the mouse: alt-click or shift-click it. Both modifiers do the same thing, because some terminals only support one or the other. The click leaves the focus and the selection where they are, so it works while you are reading a diff from another panel, or while a popup is open.
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
@@ -998,7 +1002,7 @@ When the selected line gets close to the bottom of the window and you hit down-a
That's the behavior when `gui.scrollOffBehavior` is set to "margin" (the default). If you set `gui.scrollOffBehavior` to "jump", then upon reaching the last line of a view and hitting down-arrow the view will scroll by half a page so that the selection ends up in the middle of the view. This may feel a little jarring because the cursor jumps around when continuously moving down, but it has the advantage that the view doesn't scroll as often.
This setting applies both to all list views (e.g. commits and branches etc), and to the staging view.
This setting applies both to all list views (e.g. commits and branches etc), and to focused diffs.
## Filtering
-1
View File
@@ -31,7 +31,6 @@
* `pkg/gui/keybindings`: Contains code for mapping between keybindings and their labels
* `pkg/gui/mergeconflicts`: Contains code relating to the handling of merge conflicts
* `pkg/gui/modes`: Contains code relating to the state of different modes e.g. cherry picking mode, rebase mode.
* `pkg/gui/patch_exploring`: Contains code relating to the state of patch-oriented views like the staging view.
* `pkg/gui/popup`: Contains code that lets you easily raise popups
* `pkg/gui/presentation`: Contains presentation code i.e. code concerned with rendering content inside views
* `pkg/gui/services/custom_commands`: Contains code related to user-defined custom commands.
+31 -34
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Prev screen mode | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Cancel | |
| `` ? `` | Open keybindings menu | |
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -65,7 +66,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -149,7 +150,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | View stash options | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Stage all | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage lines / Collapse directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Focus file diff / Collapse directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Discard | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
@@ -222,42 +223,21 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
## Main panel (patch building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open file | Open file in default application. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <space> `` | Toggle lines in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | Search the current view by text | |
## Main panel (staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <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. |
| `` o `` | Open file | Open file in default application. |
| `` e `` | Edit file | Open file in external editor. |
| `` <esc> `` | Return to files panel | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Commit | Commit staged changes. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Commit changes using git editor | |
@@ -327,8 +307,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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 diff lines to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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
+38 -41
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 前の画面モード | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | キャンセル | |
| `` ? `` | キーバインディングメニューを開く | |
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
@@ -114,7 +115,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 外部差分ツールを開く(git difftool) | |
| `` <space> `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 |
| `` <enter> `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。<br><br>デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 |
| `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます |
| `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します |
@@ -191,8 +192,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## タグ
@@ -244,44 +262,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` 0 `` | メインビューにフォーカス | |
| `` / `` | 現在のビューをテキストでフィルタリング | |
## メインパネル(ステージング)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <esc> `` | ファイルパネルに戻る | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(パッチ作成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` v `` | 範囲選択を切り替え | |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | パッチ内の行を切り替え | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | カスタムパッチビルダーを終了 | |
| `` / `` | 現在のビューをテキストで検索 | |
## メインパネル(マージ中)
| Key | Action | Info |
@@ -304,8 +284,25 @@ _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> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | サイドパネルに戻る | |
| `` c `` | コミット | ステージされた変更をコミットします。 |
| `` w `` | pre-commitフックなしで変更をコミット | |
| `` C `` | Gitエディタを使用して変更をコミット | |
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 現在のビューをテキストで検索 | |
## メニュー
+31 -34
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 이전 스크린 모드 | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 취소 | |
| `` ? `` | 매뉴 열기 | |
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -83,8 +84,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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
@@ -161,42 +179,21 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Patch Building)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <space> `` | Line(s)을 패치에 추가/삭제 | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Exit custom patch builder | |
| `` / `` | 검색 시작 | |
## 메인 패널 (Staging)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` v `` | 드래그 선택 전환 | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <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. |
| `` o `` | 파일 닫기 | Open file in default application. |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <esc> `` | 파일 목록으로 돌아가기 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
| `` w `` | Commit changes without pre-commit hook | |
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
@@ -345,7 +342,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -395,7 +392,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Stash 옵션 보기 | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | 모든 변경을 Staged/unstaged으로 전환 | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Stage individual hunks/lines for file, or collapse/expand for directory | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | View 'discard changes' options | View options for discarding changes to the selected file. |
| `` g `` | View upstream reset options | |
| `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). |
+31 -34
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Vorige scherm modus | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Annuleren | |
| `` ? `` | Open menu | |
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -72,7 +73,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Bekijk stash opties | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Toggle staged alle | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Stage individuele hunks/lijnen | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. |
| `` g `` | Bekijk upstream reset opties | |
| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). |
@@ -144,7 +145,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
| `` <space> `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit |
@@ -230,24 +231,25 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Patch bouwen
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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 diff lines to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <space> `` | Voeg toe/verwijder lijn(en) in patch | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sluit lijn-bij-lijn modus | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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 | |
## Reflog
@@ -305,26 +307,21 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
## Staging
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` v `` | Toggle drag selecteer | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` 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. |
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <esc> `` | Ga terug naar het bestanden paneel | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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 | |
+38 -41
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Poprzedni tryb ekranu | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Anuluj | |
| `` ? `` | Otwórz menu przypisań klawiszy | |
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
@@ -98,8 +99,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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
@@ -132,22 +150,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | Pokaż commity | |
| `` / `` | Filtruj bieżący widok po tekście | |
## Główny panel (budowanie łatki)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <space> `` | Przełącz linie w łatce | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Wyjdź z budowniczego niestandardowej łatki | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
## Input prompt
| Key | Action | Info |
@@ -200,8 +202,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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)
@@ -220,28 +239,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Wróć do panelu plików | |
## Panel główny (zatwierdzanie)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <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. |
| `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <esc> `` | Wróć do panelu plików | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
| `` 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 potwierdzenia
| Key | Action | Info |
@@ -296,7 +293,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | |
| `` <space> `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
+31 -34
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Modo de tela anterior | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Cancelar | |
| `` ? `` | Abrir o menu de atalhos do teclado | |
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -148,7 +149,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Abrir ferramenta de diff externa (git difftool) | |
| `` <space> `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos |
| `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo |
@@ -234,26 +235,21 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Painel Principal (preparação)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` 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. |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <esc> `` | Retornar ao painel de arquivos | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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 | |
@@ -284,22 +280,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Retornar ao painel de arquivos | |
## Painel principal (patch build)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` v `` | Toggle range select | |
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <space> `` | Alternar linhas no caminho | |
| `` d `` | Remover linhas do commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Sair do construtor de patch personalizado | |
| `` / `` | Pesquisar na visualização atual por texto | |
## Reflog
| Key | Action | Info |
@@ -336,8 +316,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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> `` | Copy selected diff lines to clipboard | |
| `` <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 | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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
+32 -35
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Предыдущий режим экрана | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Отменить | |
| `` ? `` | Открыть меню | |
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -73,26 +74,21 @@ _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). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
## Главная панель (Индексирование)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` 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. |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <esc> `` | Вернуться к панели файлов | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | Exit back to side panel | |
| `` c `` | Сохранить изменения | Commit staged changes. |
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
| `` C `` | Сохранить изменения с помощью редактора git | |
@@ -105,8 +101,25 @@ _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). |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` 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> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <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> |
| `` / `` | Найти | |
## Главная панель (Слияние)
@@ -125,22 +138,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | View merge conflict options | View options for resolving merge conflicts. |
| `` <esc> `` | Вернуться к панели файлов | |
## Главная панель (сборка патчей)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` v `` | Переключить выборку перетаскивания | |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` o `` | Открыть файл | Open file in default application. |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <space> `` | Добавить/удалить строку(и) для патча | |
| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. |
| `` <esc> `` | Выйти из сборщика пользовательских патчей | |
| `` / `` | Найти | |
## Журнал ссылок (Reflog)
| Key | Action | Info |
@@ -304,7 +301,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | Open external diff tool (git difftool) | |
| `` <space> `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. |
| `` <enter> `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.<br><br>The default can be changed in the config file with the key 'gui.showFileTree'. |
| `` - `` | Collapse all files | Collapse all directories in the files tree |
| `` = `` | Expand all files | Expand all directories in the file tree |
@@ -389,7 +386,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. |
| `` S `` | Просмотреть параметры хранилища | View stash options (e.g. stash all, stash staged, stash unstaged). |
| `` a `` | Все проиндексированные/непроиндексированные | Toggle staged/unstaged for all files in working tree. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. |
| `` <enter> `` | Проиндексировать отдельные части/строки для файла или свернуть/развернуть для каталога | If the selected item is a file, focus its diff so you can act on individual hunks or lines. If it is a directory, collapse or expand it. |
| `` d `` | Просмотреть параметры «отмены изменении» | View options for discarding changes to the selected file. |
| `` g `` | Просмотреть параметры сброса upstream-ветки | |
| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). |
+38 -41
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 上一屏模式 | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 取消 | |
| `` ? `` | 打开菜单 | |
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
@@ -178,7 +179,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 使用外部差异比较工具(git difftool) | |
| `` <space> `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。<br><br>可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 |
| `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 |
| `` = `` | 展开全部文件 | 展开文件树中的全部目录 |
@@ -249,22 +250,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <enter> `` | 查看提交 | |
| `` / `` | 通过文本过滤当前视图 | |
## 构建补丁中
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 添加/移除 行到补丁 | |
| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 |
| `` <esc> `` | 退出逐行模式 | |
| `` / `` | 开始搜索 | |
## 标签
| Key | Action | Info |
@@ -285,8 +270,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正在合并
@@ -305,36 +307,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 |
| `` <esc> `` | 返回文件面板 | |
## 正在暂存
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` v `` | 切换拖动选择 | |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` o `` | 打开文件 | 使用默认程序打开该文件 |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <esc> `` | 返回文件面板 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 正常
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | 退出回到侧边面板 | |
| `` c `` | 提交变更 | 提交暂存文件 |
| `` w `` | 提交变更而无需预先提交钩子 | |
| `` C `` | 使用 Git 编辑器提交变更 | |
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 开始搜索 | |
## 状态
+38 -41
View File
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 上一個螢幕模式 | |
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
@@ -52,30 +53,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` ] `` | 下一個索引標籤 | |
| `` [ `` | 上一個索引標籤 | |
## 主面板 (補丁生成)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 向 (或從) 補丁中添加/刪除行 | |
| `` d `` | 從提交中移除行 | 從本次提交中移除所選行。此操作會在背景執行互動式變基,因此如果後續提交也修改了這些行,您可能會遇到合併衝突。 |
| `` <esc> `` | 退出自訂補丁建立器 | |
| `` / `` | 搜尋 | |
## 主面板(一般)
| Key | Action | Info |
|-----|--------|-------------|
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 主面板(合併)
@@ -94,28 +96,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` M `` | 檢視合併衝突選項 | 檢視用於解決合併衝突的選項。 |
| `` <esc> `` | 返回檔案面板 | |
## 主面板(預存)
| Key | Action | Info |
|-----|--------|-------------|
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` v `` | 切換拖曳選擇 | |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` o `` | 開啟檔案 | 使用預設軟體開啟 |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <esc> `` | 返回檔案面板 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 功能表
| Key | Action | Info |
@@ -230,7 +210,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <ctrl+t> `` | 開啟外部差異工具 (git difftool) | |
| `` <space> `` | 切換檔案是否包含在補丁中 | 切換檔案是否包含在自定義補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` a `` | 切換所有檔案是否包含在補丁中 | 新增或刪除所有提交中的檔案到自定義的補丁中。請參閱 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 |
| `` <enter> `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | 如果已選擇一個檔案,則Enter進入該檔案,以便您可以向自定義補丁新增/刪除單獨的行。如果選擇了目錄,則切換目錄。 |
| `` <enter> `` | Focus file diff / Toggle directory | If a file is selected, focus its diff so you can act on individual lines. If it is a directory, collapse or expand it. |
| `` ` `` | 顯示檔案樹狀視圖 | 在平面佈局和樹佈局之間切換檔案檢視。平面佈局在單個列表中顯示所有檔案路徑,樹佈局按目錄分組檔案。<br><br>可以在設定檔中使用 'gui.showFileTree' 鍵更改預設設定。 |
| `` - `` | 摺疊全部檔案 | 摺疊檔案樹中的全部目錄 |
| `` = `` | 展開全部檔案 | 展開檔案樹中的全部目錄 |
@@ -355,8 +335,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` <tab> `` | Switch diff pane | Switch to the other focused diff pane. |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
| `` <ctrl+o> `` | Copy selected diff lines to clipboard | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <ctrl+g> `` | Jump to file | |
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
| `` <esc> `` | 退出回到側邊面板 | |
| `` c `` | 提交變更 | 提交暫存區變更 |
| `` w `` | 沒有預提交 hook 就提交更改 | |
| `` C `` | 使用 git 編輯器提交變更 | |
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
| `` / `` | 搜尋 | |
## 狀態
-3
View File
@@ -152,9 +152,6 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes
if integrationTest != nil {
integrationTest.SetupConfig(appConfig)
// Set this to true so that integration tests don't have to explicitly deal with the hunk
// staging hint:
appConfig.GetAppState().DidShowHunkStagingHint = true
// Preserve the changes that the test setup just made to the config, so
// they don't get lost when we reload the config while running the test
-7
View File
@@ -120,9 +120,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
"prompt": tr.PromptTitle,
"information": tr.InformationTitle,
"main": tr.NormalTitle,
"patchBuilding": tr.PatchBuildingTitle,
"mergeConflicts": tr.MergingTitle,
"staging": tr.StagingTitle,
"menu": tr.MenuTitle,
"search": tr.SearchTitle,
"secondary": tr.SecondaryTitle,
@@ -141,12 +139,7 @@ func localisedTitle(tr *i18n.TranslationSet, str string) string {
}
func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*bindingSection {
excludedViews := []string{"stagingSecondary", "patchBuildingSecondary"}
bindingsToDisplay := lo.Filter(bindings, func(binding *types.Binding, _ int) bool {
if lo.Contains(excludedViews, binding.ViewName) {
return false
}
return (binding.Description != "" || binding.Alternative != "") && len(binding.Keys) > 0
})
+9 -2
View File
@@ -135,8 +135,15 @@ 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)
},
func() (string, error) {
// Under lazygit's own temp dir, so that it honours the configured location
// and is cleaned up with everything else when we exit.
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
})
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()
})
}
+150 -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,19 +143,41 @@ 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(),
)
}
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
// under the directory holding them. It goes through the same wiring as any other diff we
// show, so the patch is rendered by whatever renders the rest of them, and git works out
// how much context to give it.
//
// git's own path prefixes are suppressed because the trees are named a and b themselves,
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
return self.cmd.New(
NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
NoLineEndingConversion().
Arg("--no-index").
Arg("--no-prefix").
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
Arg("a", "b").
Dir(dir).
ToArgv(),
)
}
// This is a basic generic diff command that can be used for any diff operation
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
// and does not use user configs such as ignore whitespace.
+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,42 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
return self
}
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
// NoLineEndingConversion keeps git's line-ending machinery away from files that are not
// a working tree's. The trees the custom patch is materialized into hold the bytes git
// states the patch in, so a command over them has to read and write those bytes as they
// are. On a machine that checks files out with CRLF, `git apply` writes the after tree
// in that form while the before tree keeps the LF it was written with. git's own diff
// converts both back, but it warns about a round trip through a working tree these
// files never belong to, and an external diff renderer is handed the two files as they
// stand, one line ending apart in every line.
func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder {
return self.
// The setting that converts on most machines, and the one Git for Windows
// installs itself with.
Config("core.autocrlf=false").
// An attributes file outside the repo can still mark the files as text. The
// form to keep them in is then the form they are written in.
Config("core.eol=lf").
// An attribute naming CRLF outright overrides that, and git converts after all.
// The warning it gives is about a checkout these files never have.
Config("core.safecrlf=false")
}
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 {
+89
View File
@@ -2,13 +2,16 @@ package git_commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
)
@@ -20,6 +23,10 @@ type PatchCommands struct {
stash *StashCommands
PatchBuilder *patch.PatchBuilder
// The version of the patch the diff trees were last written for, so that they are
// written again when, and only when, the patch has changed since.
treesWrittenForGeneration int
}
func NewPatchCommands(
@@ -40,6 +47,88 @@ func NewPatchCommands(
}
}
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
// longer describes the patch. Call it before rendering the patch, which is often — every
// time the panel showing it re-renders — while the patch itself changes rarely.
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
return nil
}
if err := self.WriteCustomPatchDiffTrees(); err != nil {
return err
}
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
return nil
}
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
// before the patch, `b` as it is after. Diffing those two trees against each other
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
// can then be rendered exactly as any other diff is — through a diff renderer of any
// kind, and with git's own idea of how much context to show.
//
// The trees are named a and b so that the diff's paths, with git's own prefixes
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
// repo-relative paths.
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
dir := self.PatchBuilder.TempDir()
if dir == "" {
return nil
}
before := filepath.Join(dir, "a")
after := filepath.Join(dir, "b")
for _, tree := range []string{before, after} {
if err := os.RemoveAll(tree); err != nil {
return err
}
if err := os.MkdirAll(tree, 0o700); err != nil {
return err
}
}
for _, file := range self.PatchBuilder.FilesInPatch() {
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
// A file the patch adds has no content on the before side, so git has nothing to
// show for it.
added := err != nil
// The before side holds an added file as an empty file rather than not at all, so
// that the diff pairs the two sides up and states the file's real path, instead of
// reporting a file that only one of the trees has.
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
lo.Ternary(added, "", content)); err != nil {
return err
}
// The after side is seeded with the same content, for the patch to change; a file
// the patch adds is left absent, for the patch to create.
if !added {
if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil {
return err
}
}
}
// Write added files as creations rather than as diffs against an empty file: the
// patch is applied in one go, so a file it expects to be there already would make
// the whole of it fail.
patchText := self.PatchBuilder.PatchToApply(false, false)
if strings.TrimSpace(patchText) == "" {
// Nothing in the patch, so the two trees are alike and the diff is empty.
return nil
}
patchFilePath, err := self.SaveTemporaryPatch(patchText)
if err != nil {
return err
}
return self.cmd.New(NewGitCmd("apply").
NoLineEndingConversion().
Arg(patchFilePath).
Dir(after).
ToArgv()).Run()
}
type ApplyPatchOpts struct {
ThreeWay bool
Cached bool
+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()
-14
View File
@@ -265,17 +265,3 @@ func (self *Patch) AdjustLineNumber(lineNumber int) int {
return adjustedLineNumber
}
func (self *Patch) IsSingleHunkForWholeFile() bool {
if len(self.hunks) != 1 {
return false
}
// We consider a patch to be a single hunk for the whole file if it has only additions or
// deletions but not both, and no context lines. This not quite correct, because it will also
// return true for a block of added or deleted lines if the diff context size is 0, but in this
// case you wouldn't be able to stage things anyway, so it doesn't matter.
bodyLines := self.hunks[0].bodyLines
return nLinesWithKind(bodyLines, []PatchLineKind{DELETION, CONTEXT}) == 0 ||
nLinesWithKind(bodyLines, []PatchLineKind{ADDITION, CONTEXT}) == 0
}
+217 -3
View File
@@ -1,10 +1,12 @@
package patch
import (
"os"
"sort"
"strings"
"github.com/jesseduffield/generics/maps"
"github.com/jesseduffield/generics/set"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
@@ -33,7 +35,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
@@ -60,12 +62,27 @@ type PatchBuilder struct {
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
// newTempDir makes a directory for the current patch to be materialized into, as
// two file trees that can be diffed against each other and so rendered like any
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
// patch's: made when one is started, removed when it is given up.
newTempDir func() (string, error)
tempDir string
// generation counts the changes made to the patch, so that whoever materializes it
// can tell whether what they last built still describes it — and rebuild only then,
// rather than on every render of it.
generation int
}
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
func NewPatchBuilder(
log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error),
) *PatchBuilder {
return &PatchBuilder{
Log: log,
loadFileDiff: loadFileDiff,
newTempDir: newTempDir,
}
}
@@ -73,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.makeTempDir()
p.To = to
p.From = from
p.reverse = reverse
@@ -91,6 +111,89 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
return p.fileInfoMap
}
// TempDir is the directory the patch is materialized into for rendering, and "" when
// there is none — no patch, or a directory we failed to make.
func (p *PatchBuilder) TempDir() string {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.tempDir
}
// Generation says which version of the patch this is; see the field.
func (p *PatchBuilder) Generation() int {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.generation
}
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
// Only call this with the lock held.
func (p *PatchBuilder) makeTempDir() {
p.removeTempDir()
if p.newTempDir == nil {
return
}
dir, err := p.newTempDir()
if err != nil {
p.Log.Error(err)
return
}
p.tempDir = dir
}
// removeTempDir takes the patch's materialized form away with the patch. Only call this
// with the lock held.
func (p *PatchBuilder) removeTempDir() {
if p.tempDir == "" {
return
}
if err := os.RemoveAll(p.tempDir); err != nil {
p.Log.Error(err)
}
p.tempDir = ""
}
// PatchFile records what materializing the patch needs to know about one of its files:
// where the patch expects to find it, and where its content before the patch comes from.
type PatchFile struct {
// Path is the name the patch knows the file by: for a renamed file, the name it had
// before where the patch carries the rename, and the name it was renamed to where the
// patch keeps only a content change and leaves the rename behind.
Path string
// ContentPath is where the file's content before the patch is to be found in the
// commit the patch is built from — for a renamed file always the name it had there,
// whatever the patch calls it.
ContentPath string
}
// FilesInPatch says which files the patch touches, in a stable order, and where each of
// them comes from.
func (p *PatchBuilder) FilesInPatch() []PatchFile {
fileInfoMap := p.snapshotFileInfoMap()
filenames := maps.Keys(fileInfoMap)
sort.Strings(filenames)
files := make([]PatchFile, 0, len(filenames))
for _, filename := range filenames {
info := fileInfoMap[filename]
if info.mode == UNSELECTED {
continue
}
file := PatchFile{Path: filename, ContentPath: filename}
if info.previousPath != "" {
file.ContentPath = info.previousPath
if info.mode == WHOLE {
file.Path = info.previousPath
}
}
files = append(files, file)
}
return files
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
@@ -135,6 +238,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
return err
}
p.generation++
p.addFileWhole(info)
return nil
@@ -146,6 +250,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
return err
}
p.generation++
p.removeFile(info)
return nil
@@ -162,7 +267,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
}
@@ -182,6 +287,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
@@ -193,6 +299,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
if err != nil {
return err
}
p.generation++
info.mode = PART
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
if len(info.includedLineIndices) == 0 {
@@ -291,6 +398,110 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
return info.mode
}
// LineIdentity says which change line of a file is meant — the line number it has on
// the side it belongs to, and whether it is a deletion — without reference to where
// that line sits in the file's parsed diff.
//
// It is how a diff shown in the main view speaks about its lines: what a rendered row
// resolves to is a line of a file, while the index of that line in the diff depends on
// how much of the diff is being shown and in what order a renderer laid it out.
type LineIdentity struct {
LineNumber int
IsDeletion bool
}
// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An
// addition is numbered in the new file and a deletion in the old one. Two consecutive
// deletions share the one new-file position between them, and numbering them in the
// old file keeps them apart.
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
byIdentity := map[LineIdentity]int{}
for idx, line := range parsed.Lines() {
switch {
case line.IsAddition():
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
case line.IsDeletion():
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
}
}
return byIdentity
}
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
// indices in it. A line that names no change line of the diff — a context line, or a
// line that isn't in the diff at all — contributes nothing.
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
byIdentity := ChangeLineIndexByIdentity(parsed)
indices := make([]int, 0, len(lines))
for _, line := range lines {
if idx, ok := byIdentity[line]; ok {
indices = append(indices, idx)
}
}
return indices
}
// PatchLineIndicesForLines maps change lines of filename to their indices in that
// file's diff; the patch is built in terms of those indices. everyChange reports
// whether the given lines cover all of the file's changes; this distinguishes acting
// on some of a file's lines from acting on the file itself.
func (p *PatchBuilder) PatchLineIndicesForLines(
filename string, previousPath string, lines []LineIdentity,
) (indices []int, everyChange bool, err error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
return nil, false, err
}
parsed := Parse(info.diff)
selected := set.NewFromSlice(lines)
everyChange = lo.EveryBy(maps.Keys(ChangeLineIndexByIdentity(parsed)),
func(identity LineIdentity) bool { return selected.Includes(identity) })
return ChangeLineIndicesForLines(parsed, lines), everyChange, nil
}
// IncludedLineIdentities says which change lines of filename are in the patch, as the
// identities a diff of that file shown anywhere can be compared against. Empty for a
// file that is no part of the patch.
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
identities := []LineIdentity{}
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
if included.Includes(idx) {
identities = append(identities, identity)
}
}
return identities
}
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
// their indices in the file's diff and in the order the file has them.
//
// It is how a line of the patch as it is shown names the line of the diff it came from:
// all that can be said about a line of the patch is which of the file's changes it is,
// its line numbers being the patch's own — a patch that leaves an earlier addition out
// numbers everything after it differently from the diff it was built from.
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
info, ok := p.snapshotFileInfoMap()[filename]
if !ok || info.mode == UNSELECTED {
return nil
}
included := set.NewFromSlice(info.includedLineIndices)
indices := []int{}
for idx, line := range Parse(info.diff).Lines() {
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
indices = append(indices, idx)
}
}
return indices
}
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
info, err := p.getFileInfo(filename, previousPath)
if err != nil {
@@ -304,6 +515,9 @@ func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.generation++
p.removeTempDir()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
+140
View File
@@ -0,0 +1,140 @@
package patch
import (
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
// every file's diff is the given one.
func newTestPatchBuilder(diff string) *PatchBuilder {
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
return diff, nil
},
// Nothing here renders the patch, so it needs no directory to be
// materialized into.
nil)
patchBuilder.Start("from", "to", false, true)
return patchBuilder
}
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
func TestPatchLineIndicesForLines(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
})
assert.NoError(t, err)
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
assert.True(t, everyChange, "the two changes are all the diff has")
}
// A renamed file's rename header makes its change lines sit further down the diff, and
// its old-file line numbers are of the file under its previous name.
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
{LineNumber: 2, IsDeletion: true}, // -orange
{LineNumber: 2, IsDeletion: false}, // +grape
})
assert.NoError(t, err)
assert.Equal(t, []int{9, 10}, indices)
assert.True(t, everyChange)
}
// everyChange is about the diff alone: whether anything the file changes was left out
// of the selection. What that then means for the patch is the caller's question.
func TestPatchLineIndicesForLinesEveryChange(t *testing.T) {
patchBuilder := newTestPatchBuilder(newFile)
_, everyChange, err := patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
})
assert.NoError(t, err)
assert.False(t, everyChange, "the file's third added line is left out")
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1},
{LineNumber: 2},
{LineNumber: 3},
})
assert.NoError(t, err)
assert.True(t, everyChange)
// A line the diff doesn't have doesn't stand in for one it does.
patchBuilder = newTestPatchBuilder(deletedFile)
_, everyChange, err = patchBuilder.PatchLineIndicesForLines("newfile", "", []LineIdentity{
{LineNumber: 1, IsDeletion: true},
{LineNumber: 2, IsDeletion: true},
{LineNumber: 4, IsDeletion: true},
})
assert.NoError(t, err)
assert.False(t, everyChange)
}
func TestIncludedLineIdentities(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch has nothing included.
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
// With only the deletion in, only its identity comes back.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
patchBuilder.IncludedLineIdentities("filename"))
// With the addition in as well, both do.
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
func TestFilesInPatch(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
// A file no part of the patch is no part of what the patch is materialized from.
assert.Empty(t, patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
assert.Equal(t,
[]PatchFile{{Path: "filename", ContentPath: "filename"}},
patchBuilder.FilesInPatch())
}
// A renamed file's content is under the name it had before whatever the patch calls the
// file, and the patch calls it by the name it had before only where it carries the
// rename — a partial selection has the rename stripped and names the file by the new one.
func TestFilesInPatchOfARenamedFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9}))
assert.Equal(t,
[]PatchFile{{Path: "newname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname"))
assert.Equal(t,
[]PatchFile{{Path: "oldname", ContentPath: "oldname"}},
patchBuilder.FilesInPatch())
}
// A file taken into the patch whole has every one of its change lines in it.
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
patchBuilder := newTestPatchBuilder(simpleDiff)
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
assert.ElementsMatch(t,
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
patchBuilder.IncludedLineIdentities("filename"))
}
-61
View File
@@ -921,64 +921,3 @@ func TestAdjustLineNumber(t *testing.T) {
})
}
}
func TestIsSingleHunkForWholeFile(t *testing.T) {
scenarios := []struct {
testName string
patchStr string
expectedResult bool
}{
{
testName: "simpleDiff",
patchStr: simpleDiff,
expectedResult: false,
},
{
testName: "addNewlineToEndOfFile",
patchStr: addNewlineToEndOfFile,
expectedResult: false,
},
{
testName: "removeNewlinefromEndOfFile",
patchStr: removeNewlinefromEndOfFile,
expectedResult: false,
},
{
testName: "twoHunks",
patchStr: twoHunks,
expectedResult: false,
},
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
expectedResult: false,
},
{
testName: "newFile",
patchStr: newFile,
expectedResult: true,
},
{
testName: "deletedFile",
patchStr: deletedFile,
expectedResult: true,
},
{
testName: "addNewlineToPreviouslyEmptyFile",
patchStr: addNewlineToPreviouslyEmptyFile,
expectedResult: true,
},
{
testName: "exampleHunk",
patchStr: exampleHunk,
expectedResult: false,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
patch := Parse(s.patchStr)
assert.Equal(t, s.expectedResult, patch.IsSingleHunkForWholeFile())
})
}
}
+6 -5
View File
@@ -297,6 +297,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
{[]string{"gui", "windowSize"}, "screenMode"},
{[]string{"gui", "wrapLinesInStagingView"}, "wrapLinesInDiffView"},
{[]string{"gui", "useHunkModeInStagingView"}, "useHunkModeInDiffView"},
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
}
@@ -898,11 +900,10 @@ func (c *AppConfig) SaveGlobalUserConfig() {
// AppState stores data between runs of the app like when the last update check
// was performed and which other repos have been checked out
type AppState struct {
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
DidShowHunkStagingHint bool
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
LastUpdateCheck int64
RecentRepos []string
StartupPopupVersion int
LastVersion string // this is the last version the user was using, for the purpose of showing release notes
// these are for shell commands typed in directly, not for custom commands in the lazygit config.
// For backwards compatibility we keep the old name in yaml files.
+16
View File
@@ -108,6 +108,22 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
"Renamed 'gui.windowSize' to 'screenMode'",
},
},
{
name: "Rename staging view options",
input: `gui:
wrapLinesInStagingView: false
useHunkModeInStagingView: true
`,
expected: `gui:
wrapLinesInDiffView: false
useHunkModeInDiffView: true
`,
expectedDidChange: true,
expectedChanges: []string{
"Renamed 'gui.wrapLinesInStagingView' to 'wrapLinesInDiffView'",
"Renamed 'gui.useHunkModeInStagingView' to 'useHunkModeInDiffView'",
},
},
}
for _, s := range scenarios {
+13 -7
View File
@@ -87,7 +87,7 @@ type GuiConfig struct {
MouseEvents bool `yaml:"mouseEvents"`
// If true, do not show a warning when amending a commit.
SkipAmendWarning bool `yaml:"skipAmendWarning"`
// If true, do not show a warning when discarding changes in the staging view.
// If true, do not show a warning when discarding changes from a focused diff.
SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"`
// If true, do not show warning when applying/popping the stash
SkipStashWarning bool `yaml:"skipStashWarning"`
@@ -122,10 +122,10 @@ type GuiConfig struct {
// - 'left': split the window horizontally (side panel on the left, main view on the right)
// - 'top': split the window vertically (side panel on top, main view below)
EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"`
// If true, wrap lines in the staging view to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"`
// If true, hunk selection mode will be enabled by default when entering the staging view.
UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"`
// If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"`
// If true, hunk selection mode will be enabled by default when focusing a diff.
UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"`
// One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt'
Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"`
// Format used when displaying time e.g. commit time.
@@ -511,6 +511,7 @@ type KeybindingUniversalConfig struct {
PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"`
JumpToBlock []Keybinding `yaml:"jumpToBlock"`
FocusMainView Keybinding `yaml:"focusMainView"`
JumpToFile Keybinding `yaml:"jumpToFile"`
NextMatch Keybinding `yaml:"nextMatch"`
PrevMatch Keybinding `yaml:"prevMatch"`
StartSearch Keybinding `yaml:"startSearch"`
@@ -672,6 +673,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"`
@@ -888,8 +891,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
},
MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left",
WrapLinesInStagingView: true,
UseHunkModeInStagingView: true,
WrapLinesInDiffView: true,
UseHunkModeInDiffView: true,
Language: "auto",
TimeFormat: "02 Jan 06",
ShortTimeFormat: time.Kitchen,
@@ -1035,6 +1038,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
NextBlockAlt2: Keybinding{"<tab>"},
JumpToBlock: []Keybinding{{"1"}, {"2"}, {"3"}, {"4"}, {"5"}},
FocusMainView: Keybinding{"0"},
JumpToFile: Keybinding{"<ctrl+g>"},
NextMatch: Keybinding{"n"},
PrevMatch: Keybinding{"N"},
StartSearch: Keybinding{"/"},
@@ -1182,6 +1186,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"},
-4
View File
@@ -228,10 +228,6 @@ var ValidCustomCommandContexts = []string{
"stash",
"normal",
"normalSecondary",
"staging",
"stagingSecondary",
"patchBuilding",
"patchBuildingSecondary",
"mergeConflicts",
"menu",
"confirmation",
@@ -242,6 +242,10 @@ func TestUserConfigValidate_enums(t *testing.T) {
{value: "commits,subCommits", valid: true},
{value: "invalid_value", valid: false},
{value: "commits, invalid_value", valid: false},
// The staging and patch-building panels are gone, so a config that
// still names their contexts is reported rather than fatal.
{value: "staging", valid: false},
{value: "patchBuilding", valid: false},
},
},
{
+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)
}
+34
View File
@@ -32,3 +32,37 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
assert.Equal(t, []bool{false, true}, doubleClicks)
}
func TestASwallowedClickIsNoHalfOfADoubleClick(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
doubleClicks := []bool{}
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list",
Key: MouseLeft,
Handler: func(opts ViewMouseBindingOpts) error {
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
return nil
},
})
press := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone))
release := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone))
// A click the client rejects, as it does for one landing behind a popup panel.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
assert.NoError(t, g.onKey(&press))
assert.NoError(t, g.onKey(&release))
assert.Empty(t, doubleClicks)
// The same spot clicked again once clicks are accepted. It is a click of its
// own, not the second half of the one that nothing acted on.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return true }
assert.NoError(t, g.onKey(&press))
assert.Equal(t, []bool{false}, doubleClicks)
}
+22 -5
View File
@@ -64,8 +64,8 @@ func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {
assert.True(t, status.IsTainted(), "status view should be tainted after SetContent")
assert.False(t, main.IsTainted(), "main view should not be tainted (was not modified)")
// flushContentOnly should succeed and clear status tainted flag
assert.NoError(t, g.flushContentOnly(g.views))
// flushContentOnly should clear status tainted flag
g.flushContentOnly(g.views)
assert.False(t, status.IsTainted(), "status view should not be tainted after flushContentOnly")
assert.False(t, main.IsTainted(), "main view should not be tainted after flushContentOnly")
@@ -76,11 +76,28 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) {
status, _ := setupViews(t, g)
status.SetContent("Fetching |")
assert.NoError(t, g.flushContentOnly(g.views))
g.flushContentOnly(g.views)
assert.Equal(t, "Fetching |", status.Buffer())
}
func TestForceFlushViewsContentOnlyDrawsLineFlash(t *testing.T) {
g := newTestGui(t)
_, main := setupViews(t, g)
main.Highlight = true
main.SelBgColor = ColorBlue
main.SelectedLineColorWidth = 2
main.FocusPoint(0, 0, false)
main.SetLineFlash(0)
g.ForceFlushViewsContentOnly(g.Views())
for x := main.x0 + 1; x <= main.x0+2; x++ {
_, style, _ := Screen.Get(x, main.y0+1)
assert.True(t, style.HasReverse(), "selection-bar cell at x=%d should flash", x)
}
}
func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)
@@ -231,7 +248,7 @@ func TestFlushContentOnly_DoesNotOverdrawHigherZViews(t *testing.T) {
assert.False(t, popup.IsTainted(), "popup should not be tainted")
// flushContentOnly is what spinner ticks ultimately invoke.
assert.NoError(t, g.flushContentOnly(g.views))
g.flushContentOnly(g.views)
assert.Equal(t, "P", cellAt(21, 9),
"popup region must still show popup content after flushContentOnly; "+
@@ -279,7 +296,7 @@ func TestFlushContentOnly_RedrawsTransitivelyOverlappingViews(t *testing.T) {
assert.False(t, b.IsTainted())
assert.False(t, c.IsTainted())
assert.NoError(t, g.flushContentOnly(g.views))
g.flushContentOnly(g.views)
// a redrawn (direct).
assert.Equal(t, "X", cellAt(5, 5), "a should be redrawn (tainted)")
+147 -115
View File
@@ -91,6 +91,14 @@ type ViewMouseBinding struct {
// must be a mouse key
Key KeyName
// If true, this binding is dispatched before ShouldHandleMouseEvent is
// consulted, so it fires even when a popup panel is focused and the click
// lands on a view other than that panel (which is normally swallowed). This
// is the same early phase that hyperlink clicks are handled in; use it for
// clicks that must stay live behind a popup, e.g. opening a diff line in the
// editor from the main view behind the commit-message panel.
HandleWhenPopupPanelFocused bool
}
type ViewMouseBindingOpts struct {
@@ -236,10 +244,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 {
@@ -410,13 +421,12 @@ func (g *Gui) Size() (x, y int) {
// corner of the terminal. It checks if the position is valid and applies
// the given colors.
// Should only be used if you know that the given rune is not part of a grapheme cluster.
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error {
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) {
if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY {
// swallowing error because it's not that big of a deal
return nil
return
}
tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode)
return nil
}
// SetView creates a new view with its top-left corner at (x0, y0)
@@ -930,12 +940,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 {
@@ -1165,7 +1196,8 @@ func (g *Gui) processEvent() error {
contentOnly = contentOnly && remainingContentOnly
if contentOnly {
return g.flushContentOnly(g.views)
g.flushContentOnly(g.views)
return nil
}
return g.flush()
}
@@ -1206,7 +1238,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 +1267,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).
@@ -1259,7 +1298,7 @@ func (g *Gui) onResize() {
}
// drawFrameEdges draws the horizontal and vertical edges of a view.
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
runeH, runeV := '─', '│'
if len(v.FrameRunes) >= 2 {
runeH, runeV = v.FrameRunes[0], v.FrameRunes[1]
@@ -1270,14 +1309,10 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
continue
}
if v.y0 > -1 && v.y0 < g.maxY {
if err := g.SetRune(x, v.y0, runeH, fgColor, bgColor); err != nil {
return err
}
g.SetRune(x, v.y0, runeH, fgColor, bgColor)
}
if v.y1 > -1 && v.y1 < g.maxY {
if err := g.SetRune(x, v.y1, runeH, fgColor, bgColor); err != nil {
return err
}
g.SetRune(x, v.y1, runeH, fgColor, bgColor)
}
}
@@ -1287,19 +1322,14 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
continue
}
if v.x0 > -1 && v.x0 < g.maxX {
if err := g.SetRune(v.x0, y, runeV, fgColor, bgColor); err != nil {
return err
}
g.SetRune(v.x0, y, runeV, fgColor, bgColor)
}
if v.x1 > -1 && v.x1 < g.maxX {
runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, y, runeV)
if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil {
return err
}
g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor)
}
}
return nil
}
func calcScrollbarRune(
@@ -1399,17 +1429,13 @@ func corner(v *View, directions byte) rune {
}
// drawFrameCorners draws the corners of the view.
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error {
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
if v.y0 == v.y1 {
if !g.SupportOverlaps && v.x0 >= 0 && v.x1 >= 0 && v.y0 >= 0 && v.x0 < g.maxX && v.x1 < g.maxX && v.y0 < g.maxY {
if err := g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor); err != nil {
return err
}
if err := g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor); err != nil {
return err
}
g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor)
g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor)
}
return nil
return
}
runeTL, runeTR, runeBL, runeBR := '┌', '┐', '└', '┘'
@@ -1430,18 +1456,15 @@ func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error {
for _, c := range corners {
if c.x >= 0 && c.y >= 0 && c.x < g.maxX && c.y < g.maxY {
if err := g.SetRune(c.x, c.y, c.ch, fgColor, bgColor); err != nil {
return err
}
g.SetRune(c.x, c.y, c.ch, fgColor, bgColor)
}
}
return nil
}
// drawTitle draws the title of the view.
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
if v.y0 < 0 || v.y0 >= g.maxY {
return nil
return
}
tabs := v.Tabs
@@ -1477,9 +1500,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
x := v.x0 + 2
for _, ch := range prefix {
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
g.SetRune(x, v.y0, ch, fgColor, bgColor)
x += uniseg.StringWidth(string(ch))
}
for i, ch := range str {
@@ -1502,64 +1523,55 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
currentFgColor &= ^AttrBold
}
}
if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil {
return err
}
g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor)
x += uniseg.StringWidth(string(ch))
}
return nil
}
// drawSubtitle draws the subtitle of the view.
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) {
if v.y0 < 0 || v.y0 >= g.maxY {
return nil
return
}
start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle)
if start < v.x0 {
return nil
return
}
x := start
for _, ch := range v.Subtitle {
if x >= v.x1 {
break
}
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
g.SetRune(x, v.y0, ch, fgColor, bgColor)
x += uniseg.StringWidth(string(ch))
}
return nil
}
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) {
if len(v.buf.lines) == 0 {
return nil
return
}
message := v.Footer
if v.y1 < 0 || v.y1 >= g.maxY {
return nil
return
}
start := v.x1 - 1 - uniseg.StringWidth(message)
if start < v.x0 {
return nil
return
}
x := start
for _, ch := range message {
if x >= v.x1 {
break
}
if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil {
return err
}
g.SetRune(x, v.y1, ch, fgColor, bgColor)
x += uniseg.StringWidth(string(ch))
}
return nil
}
// flush updates the gui, re-drawing frames and buffers.
@@ -1587,40 +1599,35 @@ func (g *Gui) flush() error {
}
}
for _, v := range g.views {
if err := g.draw(v); err != nil {
return err
}
g.draw(v)
}
Screen.Show()
return nil
}
// Redraws only tainted views and skips the layout pass.
// Redraws only dirty views and skips the layout pass.
// tcell's cell-level dirty tracking ensures only
// actually-changed cells are emitted to the terminal.
// Will also redraw any views that overlap tainted views
func (g *Gui) flushContentOnly(views []*View) error {
// Will also redraw any views that overlap dirty views.
func (g *Gui) flushContentOnly(views []*View) {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
return nil
return
}
for _, v := range viewsToRedrawContentOnly(views) {
if err := g.draw(v); err != nil {
return err
}
g.draw(v)
}
Screen.Show()
return nil
}
func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
if !v.NeedsRedraw() && !redrawIndexes.Includes(i) {
continue
}
@@ -1650,11 +1657,11 @@ func (g *Gui) ForceLayoutAndRedraw() error {
return g.flush()
}
// Redraws only tainted views outside of the normal main
// Redraws only dirty views outside of the normal main
// loop, without a layout pass. Useful during longer operations that block the
// main thread, e.g. to update a spinner in a status view.
func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views)
func (g *Gui) ForceFlushViewsContentOnly(views []*View) {
g.flushContentOnly(views)
}
// hasFocus reports whether a view is drawn as focused. Views that are embedded
@@ -1672,9 +1679,9 @@ func outermostView(v *View) *View {
}
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error {
func (g *Gui) draw(v *View) {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
return nil
return
}
if g.Cursor {
@@ -1713,30 +1720,18 @@ func (g *Gui) draw(v *View) error {
}
}
if err := g.drawFrameEdges(v, frameColor, bgColor); err != nil {
return err
}
if err := g.drawFrameCorners(v, frameColor, bgColor); err != nil {
return err
}
g.drawFrameEdges(v, frameColor, bgColor)
g.drawFrameCorners(v, frameColor, bgColor)
if v.Title != "" || len(v.Tabs) > 0 {
if err := g.drawTitle(v, fgColor, bgColor); err != nil {
return err
}
g.drawTitle(v, fgColor, bgColor)
}
if v.Subtitle != "" {
if err := g.drawSubtitle(v, fgColor, bgColor); err != nil {
return err
}
g.drawSubtitle(v, fgColor, bgColor)
}
if v.Footer != "" && g.ShowListFooter {
if err := g.drawListFooter(v, fgColor, bgColor); err != nil {
return err
}
g.drawListFooter(v, fgColor, bgColor)
}
}
return nil
}
// onKey manages key-press events. A keybinding handler is called when
@@ -1820,6 +1815,25 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
}
var mouseOpts ViewMouseBindingOpts
if IsMouseKey(ev.Key) {
mouseOpts = ViewMouseBindingOpts{
X: newX, Y: newY, Key: ev.Key.KeyName(),
IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v),
}
// Dispatch bindings that opt into firing while a popup panel is focused
// before the gate below gets a chance to reject the click.
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
if err != nil {
return err
}
if matched {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
return nil
}
}
if g.ShouldHandleMouseEvent != nil {
if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) {
// Give clients a chance to reject clicks, for example clicks in inactive views
@@ -1827,11 +1841,21 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
break
}
}
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
// Bindings match modifiers exactly, so a gesture with a keyboard modifier held
// is a gesture of its own, and the bindings that act on a plain click pass it
// by. It therefore has to leave the view as it found it: the cursor stays where
// the selection is (a list view draws its selection at the cursor), and no
// mouse capture begins for a drag that no binding will extend. ModMotion comes
// from the mouse rather than the keyboard, and every drag carries it, so it is
// masked out here.
gestureIsModified := ev.Key.Mod()&^ModMotion != ModNone
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod() == ModNone {
g.captureMouse(v)
}
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease &&
!gestureIsModified {
cursorX, cursorY := newCx, newCy
// A captured drag can report positions outside the view; keep the
// view cursor inside its bounds in that case. Handlers still get
@@ -1869,9 +1893,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if IsMouseKey(ev.Key) {
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
matched, err := g.execMouseKeybindings(v, ev, opts)
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
if err != nil {
return err
}
@@ -1903,43 +1926,49 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
return nil
}
// remember the information for this click, and return true if it was a double click
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
// isDoubleClick reports whether this click follows one just like it, closely
// enough in time to count as a double click.
func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View) bool {
return g.lastClick != nil &&
!IsMouseScrollKey(key) &&
key != MouseRelease &&
x == g.lastClick.x &&
y == g.lastClick.y &&
key == g.lastClick.key &&
v.Name() == g.lastClick.viewName &&
time.Now().Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
}
// recordClickInfo remembers this click as the one a following click is compared
// against. Only the clicks that reach a binding are recorded, so a click the
// client rejects leaves double-click detection where it was.
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) {
if IsMouseScrollKey(key) {
g.lastClick = nil
return false
return
}
// A release ends a gesture but is not a click of its own; it must leave
// the click info of the press that started it alone, or no double click
// could ever be detected.
if key == MouseRelease {
return false
return
}
clickInfo := &clickInfo{
g.lastClick = &clickInfo{
x: x,
y: y,
key: key,
viewName: v.Name(),
time: time.Now(),
}
isDoubleClick := g.lastClick != nil &&
clickInfo.x == g.lastClick.x &&
clickInfo.y == g.lastClick.y &&
clickInfo.key == g.lastClick.key &&
clickInfo.viewName == g.lastClick.viewName &&
clickInfo.time.Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
g.lastClick = clickInfo
return isDoubleClick
}
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() &&
ev.Key.KeyName() == binding.Key &&
ev.Key.Mod() == binding.Modifier
ev.Key.Mod() == binding.Modifier &&
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
}
// first pass looks for ones that match the focused view
@@ -2114,6 +2143,9 @@ func (g *Gui) Suspend() error {
return errors.New("Already suspended")
}
for _, view := range g.views {
view.ClearLineFlash()
}
g.suspended = true
if err := g.screen.Suspend(); err != nil {
+46
View File
@@ -0,0 +1,46 @@
package gocui
import (
"testing"
"github.com/gdamore/tcell/v3"
"github.com/stretchr/testify/assert"
)
func TestAModifiedClickNoBindingWantsLeavesTheViewAlone(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
g := newTestGui(t)
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
clicks := 0
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "list",
Key: MouseLeft,
Handler: func(ViewMouseBindingOpts) error {
clicks++
return nil
},
})
click := func(y int, modifier tcell.ModMask) {
for _, button := range []tcell.ButtonMask{tcell.ButtonPrimary, tcell.ButtonNone} {
event := gocuiEventFromTcellEvent(
tcell.NewEventMouse(view.x0+1, y, button, modifier))
assert.NoError(t, g.onKey(&event))
}
}
// A plain click is the binding's, and moves the cursor it acts on.
click(view.y0+4, tcell.ModNone)
assert.Equal(t, 1, clicks)
assert.Equal(t, 3, view.CursorY())
// An alt-click is nobody's here, bindings matching modifiers exactly. It has to
// leave the cursor where the selection is, or a list view would draw its
// selection on a line its owner never selected.
click(view.y0+8, tcell.ModAlt)
assert.Equal(t, 1, clicks)
assert.Equal(t, 3, view.CursorY())
assert.Nil(t, g.mouseCapture, "and no drag begins for it either")
}
+49
View File
@@ -0,0 +1,49 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestOnlyBindingsThatOptedInFireBehindAFocusedPopup(t *testing.T) {
g := newTestGui(t)
_, _ = g.SetView("main", 0, 0, 20, 10, 0)
fired := []string{}
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "main",
Key: MouseLeft,
Modifier: ModAlt,
HandleWhenPopupPanelFocused: true,
Handler: func(ViewMouseBindingOpts) error {
fired = append(fired, "opted in")
return nil
},
})
g.SetViewClickBinding(&ViewMouseBinding{
ViewName: "main",
Key: MouseLeft,
Handler: func(ViewMouseBindingOpts) error {
fired = append(fired, "ordinary")
return nil
},
})
// This is how a client reports that a popup panel has the focus and the click
// landed on a view behind it.
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, MouseX: 3, MouseY: 4,
Key: NewKey(MouseLeft, "", ModAlt),
}))
assert.Equal(t, []string{"opted in"}, fired)
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, MouseX: 3, MouseY: 4,
Key: NewKey(MouseLeft, "", ModNone),
}))
assert.Equal(t, []string{"opted in"}, fired,
"a binding that didn't opt in must still be swallowed")
}
+12 -1
View File
@@ -18,7 +18,7 @@ func TestFlushIsNoOpWhileSuspended(t *testing.T) {
flush func(g *Gui) error
}{
{"flush", func(g *Gui) error { return g.flush() }},
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
{"flushContentOnly", func(g *Gui) error { g.flushContentOnly(g.views); return nil }},
}
for _, tc := range tests {
@@ -67,3 +67,14 @@ func TestResumeSchedulesRedraw(t *testing.T) {
assert.Equal(t, eventResize, ev.Type,
"resuming must schedule a redraw; without one the screen stays blank until the next event arrives")
}
func TestSuspendClearsLineFlashes(t *testing.T) {
g := newTestGui(t)
v, err := g.SetView("main", 0, 0, 20, 10, 0)
assert.ErrorIs(t, err, ErrUnknownView)
v.SetLineFlash(3)
assert.NoError(t, g.Suspend())
assert.Equal(t, -1, v.lineFlashY)
assert.NoError(t, g.Resume())
}
+11 -3
View File
@@ -209,6 +209,7 @@ const (
var (
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
lastMouseMod tcell.ModMask = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
@@ -377,6 +378,12 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
newButtonPress = true
lastMouseKey = button
// The keyboard modifiers held at press time apply to the whole gesture:
// the press, every drag event, and the release. Snapshotting them here
// keeps a modified press from producing events that match unmodified
// bindings, and ignores modifier changes while the button is held.
lastMouseMod = tev.Modifiers()
mouseMod = Modifier(lastMouseMod)
switch button {
case tcell.ButtonPrimary:
mouseKey = MouseLeft
@@ -402,7 +409,8 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
case tcell.ButtonMiddle:
default:
}
mouseMod = ModNone
mouseMod = Modifier(lastMouseMod)
lastMouseMod = tcell.ModNone
lastMouseKey = tcell.ButtonNone
}
default:
@@ -433,10 +441,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
// reaches drag bindings instead of being delivered with the
// default MouseRelease key.
dragState = DRAGGING
mouseMod = ModMotion
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseKey = MouseLeft
case DRAGGING:
mouseMod = ModMotion
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseKey = MouseLeft
}
}
+37 -3
View File
@@ -36,21 +36,55 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
}
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, dragEvent.Type)
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
}
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
}
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
assert.Equal(t, eventMouse, pressEvent.Type)
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
assert.Equal(t, ModShift, pressEvent.Key.Mod())
assert.Equal(t, eventMouse, releaseEvent.Type)
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
}
func resetMouseState() {
lastMouseKey = tcell.ButtonNone
lastMouseMod = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
+229 -42
View File
@@ -80,18 +80,27 @@ type View struct {
// a user starts a range select and then moves the cursor up.
rangeSelectStartY int
// The view line whose selection-width bar is temporarily reversed. A value
// of -1 means that no line is flashing.
lineFlashY int
// readBuffer is used for storing unread bytes
readBuffer []byte
// tained is true if the viewLines must be updated
tainted bool
// needsRedraw is true if the view's current state has not been drawn to the
// screen yet. A tainted view always needs a redraw, but draw-only state can
// require one without invalidating viewLines.
needsRedraw bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to or highlighted since viewLines was last refreshed, and whose
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
// below it are unchanged and can reuse their cached wrapping instead of
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
// scrolling appends new lines to a long buffer.
// written to since viewLines was last refreshed, and whose cached wrapping
// (lineType.wrappedCells) may therefore be stale. Lines below it are
// unchanged and can reuse their cached wrapping instead of being
// re-wrapped, which keeps refreshViewLinesIfNeeded cheap while scrolling
// appends new lines to a long buffer.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
@@ -164,6 +173,23 @@ 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
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
// set once, when the view is created.
InclusionGutterMarker string
InclusionGutterMarkerColor Attribute
// showInclusionGutter reserves the gutter's columns at the left of every line,
// and inclusionGutterMarks, indexed by line of the content, says which lines get
// the marker. Set together, via SetInclusionGutter.
showInclusionGutter bool
inclusionGutterMarks []bool
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -257,11 +283,18 @@ type pos struct {
// 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.markViewLinesDirty()
v.viewLines = nil
v.clearHover()
}
// markViewLinesDirty records that the cached viewLines no longer represent the
// view's buffer or wrapping, so both rebuilding and redrawing are required.
func (v *View) markViewLinesDirty() {
v.tainted = true
v.needsRedraw = true
}
// 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
@@ -621,10 +654,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
@@ -763,11 +809,13 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
Frame: true,
Editor: DefaultEditor,
tainted: true,
needsRedraw: true,
outMode: mode,
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
TextArea: &TextArea{},
rangeSelectStartY: -1,
lineFlashY: -1,
TabWidth: 4,
}
@@ -841,6 +889,38 @@ func (v *View) Name() string {
return v.name
}
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
// which marks — indexed by line of the content — say which lines get
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
// content is drawn shifted past it.
//
// It is drawn over the content rather than written into it, so the content itself —
// and with it what each line of the view means, where a click lands, and how the
// lines wrap — is untouched but for the width the gutter takes.
func (v *View) SetInclusionGutter(show bool, marks []bool) {
v.writeMutex.Lock()
changed := v.showInclusionGutter != show
v.showInclusionGutter = show
v.inclusionGutterMarks = marks
v.writeMutex.Unlock()
if changed {
// The gutter takes its columns from the content, so what is left of it wraps
// differently, and everything pointing into it has to come along.
v.RewrapContent()
}
}
// inclusionGutterWidth is how many columns the inclusion gutter takes while it is
// shown — the marker plus a column of space before the content — and 0 while it is
// not. Only call this with a lock on writeMutex.
func (v *View) inclusionGutterWidth() int {
if !v.showInclusionGutter {
return 0
}
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
}
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
// position is valid.
@@ -863,7 +943,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
@@ -886,6 +967,10 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
fgColor |= AttrUnderline
}
if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) {
fgColor ^= AttrReverse
}
// Don't display empty characters
if ch == "" {
ch = " "
@@ -1106,7 +1191,7 @@ func (v *View) write(p []byte) {
return
}
v.tainted = true
v.markViewLinesDirty()
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
@@ -1464,9 +1549,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()
@@ -1541,7 +1626,7 @@ func (v *View) SwapInOffscreenRender() {
}
v.buf = v.offscreen
v.offscreen = nil
v.tainted = true
v.markViewLinesDirty()
v.clearHover()
}
@@ -1705,6 +1790,12 @@ func (v *View) IsTainted() bool {
return v.tainted
}
func (v *View) NeedsRedraw() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.needsRedraw
}
// draw re-draws the view's contents.
func (v *View) draw(isWindowFocused bool) {
v.writeMutex.Lock()
@@ -1713,6 +1804,7 @@ func (v *View) draw(isWindowFocused bool) {
if !v.Visible {
return
}
defer func() { v.needsRedraw = false }()
v.clearRunes()
@@ -1744,6 +1836,8 @@ func (v *View) draw(isWindowFocused bool) {
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
gutterWidth := v.inclusionGutterWidth()
for y, vline := range v.viewLines[start:] {
if y >= maxY {
break
@@ -1758,10 +1852,20 @@ func (v *View) draw(isWindowFocused bool) {
trailingCell.bgColor = attrs.bg
}
// The inclusion gutter is blank but for the marker on a marked line, and the
// content begins after it. The blanks go through setCharacter like everything
// else, so that a selection reaching the left edge covers the gutter too.
for gx := range gutterWidth {
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
}
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
}
// x tracks the current x position in the view, and cellIdx tracks the
// index of the cell. If we print a double-sized rune, we increment cellIdx
// by one but x by two.
x := -v.ox
x := gutterWidth - v.ox
cellIdx := 0
var c cell
@@ -1775,7 +1879,7 @@ func (v *View) draw(isWindowFocused bool) {
// no more characters to write so we're only going to be printing empty cells
// past this point
x = 0
x = gutterWidth
}
// if we're out of cells to write, we'll just print empty cells.
@@ -1810,10 +1914,11 @@ func (v *View) refreshViewLinesIfNeeded() {
return
}
maxX := v.InnerWidth()
wrap := 0
if v.Wrap {
wrap = maxX
// The inclusion gutter, while it is shown, takes its columns out of the width
// the content has to wrap in.
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
}
lineIdx := 0
@@ -1824,8 +1929,8 @@ func (v *View) refreshViewLinesIfNeeded() {
// Reuse the previously wrapped result for lines that haven't changed
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
// the current width. Wrapping is expensive and this loop runs on every
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
// scroll event, so only the lines that were actually just read should
// be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
@@ -1955,6 +2060,26 @@ func (v *View) BufferLines() []string {
return lines
}
// MarkedLines returns the lines of the view's content that the inclusion gutter is
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
// is hidden.
func (v *View) MarkedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if !v.showInclusionGutter {
return nil
}
lines := []string{}
for i, line := range v.buf.lines {
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
lines = append(lines, line.cells.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
@@ -1983,9 +2108,60 @@ func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
contents := make([]DiffLineContent, len(v.buf.lines))
for i := range v.buf.lines {
line := &v.buf.lines[i]
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) {
@@ -2063,6 +2239,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)
}
@@ -2145,28 +2324,22 @@ func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
return fgColor | selTextColor&AttrStyleBits
}
// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
if y < 0 || y >= len(v.buf.lines) {
return
}
// SetLineFlash temporarily marks a view line without moving or changing the
// selection. The caller owns the lifetime and clears it with ClearLineFlash.
func (v *View) SetLineFlash(viewLine int) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
cells := make([]cell, 0, len(v.buf.lines[y].cells))
for _, c := range v.buf.lines[y].cells {
if on {
c.bgColor = v.SelBgColor
c.fgColor = v.SelFgColor
} else {
c.bgColor = v.BgColor
c.fgColor = v.FgColor
}
cells = append(cells, c)
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.buf.lines[y].cells = cells
v.clearHover()
v.lineFlashY = viewLine
v.needsRedraw = true
}
func (v *View) ClearLineFlash() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = -1
v.needsRedraw = true
}
func lineWrap(line []cell, columns int) [][]cell {
@@ -2272,6 +2445,20 @@ 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()
+114
View File
@@ -1093,6 +1093,32 @@ func TestSelectedLinesOfWrappedContent(t *testing.T) {
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Highlight = true
v.SelBgColor = ColorBlue
v.SelectedLineColorWidth = 2
v.writeString("one\ntwo\nthree\n")
v.FocusPoint(0, 1, false)
v.SetLineFlash(1)
v.draw(true)
for x := 1; x <= 2; x++ {
_, style, _ := Screen.Get(x, 2)
assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x)
}
_, style, _ := Screen.Get(3, 2)
assert.False(t, style.HasReverse(), "the flash should stop after the selection bar")
assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection")
v.ClearLineFlash()
v.draw(true)
_, style, _ = Screen.Get(1, 2)
assert.False(t, style.HasReverse(), "clearing should remove the flash")
}
// 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.
@@ -1119,3 +1145,91 @@ func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
}
// The inclusion gutter reserves columns at the left of every line, draws its marker
// on the marked lines only, and moves the content out of the way.
func TestInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
// InnerWidth 10; the frame puts view x=0 at screen x=1.
v := NewView("name", 0, 0, 11, 5, OutputNormal)
v.Wrap = true
v.InclusionGutterMarker = "✓"
v.writeString("aaa\nbbb\nccc\n")
// The gutter is two columns wide — the marker and a space; mark the middle line.
v.SetInclusionGutter(true, []bool{false, true, false})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "a marked line has one")
chr, _, _ = Screen.Get(1, 3)
assert.Equal(t, " ", chr, "an unmarked line has no marker")
// The content begins after the gutter: view x=2, i.e. screen x=3.
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "a", chr)
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "b", chr)
chr, _, _ = Screen.Get(3, 3)
assert.Equal(t, "c", chr)
// Hiding the gutter puts the content back at the left edge.
v.SetInclusionGutter(false, nil)
v.draw(true)
chr, _, _ = Screen.Get(1, 1)
assert.Equal(t, "a", chr)
}
// A marked line the view wraps is marked on every segment it is drawn as, so that
// the mark doesn't look like it belongs to the first part of the line alone. The
// gutter takes its columns out of the width the content wraps in.
func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.Wrap = true
v.InclusionGutterMarker = "✓"
// Ten cells, wrapping at eight once the two-column gutter is shown.
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.draw(true)
chr, _, _ := Screen.Get(1, 1)
assert.Equal(t, "✓", chr)
chr, _, _ = Screen.Get(3, 1)
assert.Equal(t, "0", chr)
chr, _, _ = Screen.Get(10, 1)
assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it")
chr, _, _ = Screen.Get(1, 2)
assert.Equal(t, "✓", chr, "the line's second segment is marked too")
chr, _, _ = Screen.Get(3, 2)
assert.Equal(t, "8", chr)
}
// Showing the gutter narrows the content, so the content wraps again — and the
// positions into it, which count the segments lines are drawn as, have to come
// along, as they do for any other change of width.
func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
v.InclusionGutterMarker = "✓"
v.writeString("one\ntwo\nthree\nsomethingfartoolong\n")
assert.Equal(t, 5, v.ViewLinesHeight())
v.FocusPoint(0, 2, false)
assert.Equal(t, "three", v.SelectedLine())
// With eight columns left for the content, the last line wraps into three
// segments rather than two.
v.SetInclusionGutter(true, []bool{false, false, true, false})
assert.Equal(t, 6, v.ViewLinesHeight())
assert.Equal(t, "three", v.SelectedLine())
}
+14 -15
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()
@@ -350,18 +350,6 @@ func (self *ContextMgr) AllList() []types.IListContext {
return listContexts
}
func (self *ContextMgr) AllPatchExplorer() []types.IPatchExplorerContext {
var listContexts []types.IPatchExplorerContext
for _, context := range self.allContexts.Flatten() {
if listContext, ok := context.(types.IPatchExplorerContext); ok {
listContexts = append(listContexts, listContext)
}
}
return listContexts
}
func (self *ContextMgr) ContextForKey(key types.ContextKey) types.Context {
self.RLock()
defer self.RUnlock()
@@ -399,3 +387,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()) {
+37 -6
View File
@@ -19,11 +19,36 @@ 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)
_ types.PullRequestDiffContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest asks the panel this one was entered from: the files are a
// commit's, and which branch's pull request that commit is up for review in is known
// there rather than here.
func (self *CommitFilesContext) BranchForPullRequest() string {
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
return parent.BranchForPullRequest()
}
return ""
}
// CommitsForPullRequest asks the panel this one was entered from as well: the files
// listed here are those of the commits selected there.
func (self *CommitFilesContext) CommitsForPullRequest() ([]*models.Commit, string) {
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
return parent.CommitsForPullRequest()
}
return nil, ""
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
@@ -80,10 +105,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()
}
+42 -58
View File
@@ -8,27 +8,23 @@ const (
// used as a nil value when passing a context key as an arg
NO_CONTEXT types.ContextKey = "none"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
STAGING_MAIN_CONTEXT_KEY types.ContextKey = "staging"
STAGING_SECONDARY_CONTEXT_KEY types.ContextKey = "stagingSecondary"
PATCH_BUILDING_MAIN_CONTEXT_KEY types.ContextKey = "patchBuilding"
PATCH_BUILDING_SECONDARY_CONTEXT_KEY types.ContextKey = "patchBuildingSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
GLOBAL_CONTEXT_KEY types.ContextKey = "global"
STATUS_CONTEXT_KEY types.ContextKey = "status"
SNAKE_CONTEXT_KEY types.ContextKey = "snake"
FILES_CONTEXT_KEY types.ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches"
REMOTES_CONTEXT_KEY types.ContextKey = "remotes"
WORKTREES_CONTEXT_KEY types.ContextKey = "worktrees"
REMOTE_BRANCHES_CONTEXT_KEY types.ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY types.ContextKey = "tags"
LOCAL_COMMITS_CONTEXT_KEY types.ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY types.ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY types.ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY types.ContextKey = "commitFiles"
STASH_CONTEXT_KEY types.ContextKey = "stash"
NORMAL_MAIN_CONTEXT_KEY types.ContextKey = "normal"
NORMAL_SECONDARY_CONTEXT_KEY types.ContextKey = "normalSecondary"
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
// these shouldn't really be needed for anything but I'm giving them unique keys nonetheless
OPTIONS_CONTEXT_KEY types.ContextKey = "options"
@@ -66,10 +62,6 @@ var AllContextKeys = []types.ContextKey{
STASH_CONTEXT_KEY,
NORMAL_MAIN_CONTEXT_KEY,
NORMAL_SECONDARY_CONTEXT_KEY,
STAGING_MAIN_CONTEXT_KEY,
STAGING_SECONDARY_CONTEXT_KEY,
PATCH_BUILDING_MAIN_CONTEXT_KEY,
PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
MERGE_CONFLICTS_CONTEXT_KEY,
MENU_CONTEXT_KEY,
@@ -83,35 +75,31 @@ var AllContextKeys = []types.ContextKey{
}
type ContextTree struct {
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
Staging *PatchExplorerContext
StagingSecondary *PatchExplorerContext
CustomPatchBuilder *PatchExplorerContext
CustomPatchBuilderSecondary types.Context
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
Global types.Context
Status types.Context
Snake types.Context
Files *WorkingTreeContext
Menu *MenuContext
Branches *BranchesContext
Tags *TagsContext
LocalCommits *LocalCommitsContext
CommitFiles *CommitFilesContext
Remotes *RemotesContext
Worktrees *WorktreesContext
Submodules *SubmodulesContext
RemoteBranches *RemoteBranchesContext
ReflogCommits *ReflogCommitsContext
SubCommits *SubCommitsContext
Stash *StashContext
Suggestions *SuggestionsContext
Normal *MainContext
NormalSecondary *MainContext
MergeConflicts *MergeConflictsContext
Confirmation *ConfirmationContext
Prompt *PromptContext
CommitMessage *CommitMessageContext
CommitDescription types.Context
CommandLog types.Context
// display contexts
AppStatus types.Context
@@ -149,10 +137,6 @@ func (self *ContextTree) Flatten() []types.Context {
self.CommitDescription,
self.MergeConflicts,
self.StagingSecondary,
self.Staging,
self.CustomPatchBuilderSecondary,
self.CustomPatchBuilder,
self.NormalSecondary,
self.Normal,
+62 -3
View File
@@ -31,11 +31,70 @@ 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)
_ types.PullRequestDiffContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest returns the checked-out branch: this panel shows its commits,
// so a pull request for it is where they are up for review.
func (self *LocalCommitsContext) BranchForPullRequest() string {
return self.ListContextTrait.c.Model().CheckedOutBranch
}
func (self *LocalCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
selectedCommits, _, _ := self.GetSelectedItems()
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
}
// commitsShownInDiff returns the commits whose combined diff a panel listing a branch's
// commits renders into the main view: the selected range where it has a range to diff,
// and the commit at the cursor otherwise. The panel hands the same two to
// DiffHelper.GetUpdateTaskForRenderingCommitsDiff, so anything acting on the diff on
// screen acts on the commits that diff is of.
func commitsShownInDiff(
selectedCommits []*models.Commit, commitAtCursor *models.Commit, refRange *types.RefRange,
) []*models.Commit {
if refRange != nil {
return selectedCommits
}
if commitAtCursor == nil {
return nil
}
return []*models.Commit{commitAtCursor}
}
// pullRequestBaseForCommits returns the hash of the commit the diff of the given commits
// starts after: the parent of the oldest of them. A pull request holds only the commits
// of the branch that are pushed, so a parent that isn't pushed is none of its own. The
// diff then starts where the pull request itself does, and "" says so.
func pullRequestBaseForCommits(allCommits []*models.Commit, commits []*models.Commit) string {
if len(commits) == 0 {
return ""
}
oldest := commits[len(commits)-1]
if oldest.IsFirstCommit() {
return ""
}
parentHash := oldest.Parents()[0]
parentIsInPullRequest := lo.ContainsBy(allCommits, func(commit *models.Commit) bool {
return commit.Hash() == parentHash && commit.Status == models.StatusPushed
})
if !parentIsInPullRequest {
return ""
}
return parentHash
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
@@ -4,8 +4,12 @@ import (
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@@ -51,3 +55,101 @@ func TestAddMovingCommitsIndicator(t *testing.T) {
},
}, items)
}
func TestCommitsShownInDiff(t *testing.T) {
hashPool := &utils.StringPool{}
newer := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newer"})
older := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "older"})
selected := []*models.Commit{newer, older}
scenarios := []struct {
name string
selectedCommit *models.Commit
refRange *types.RefRange
expected []*models.Commit
}{
{
name: "a range is diffed as a whole",
selectedCommit: newer,
refRange: &types.RefRange{From: older, To: newer},
expected: selected,
},
{
name: "without a range to diff, only the commit at the cursor is",
selectedCommit: newer,
expected: []*models.Commit{newer},
},
{
name: "nothing is diffed while nothing is selected",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, commitsShownInDiff(selected, s.selectedCommit, s.refRange))
})
}
}
func TestPullRequestBaseForCommits(t *testing.T) {
hashPool := &utils.StringPool{}
commit := func(hash string, parent string, status models.CommitStatus) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{
Hash: hash,
Parents: lo.Ternary(parent == "", []string{}, []string{parent}),
Status: status,
})
}
// A branch of three pushed commits whose tip was amended, on top of a commit that
// is in a main branch already, as the panel lists them: newest first.
amended := commit("amended", "third", models.StatusUnpushed)
third := commit("third", "second", models.StatusPushed)
second := commit("second", "first", models.StatusPushed)
first := commit("first", "merged", models.StatusPushed)
merged := commit("merged", "ancient", models.StatusMerged)
ancient := commit("ancient", "", models.StatusMerged)
allCommits := []*models.Commit{amended, third, second, first, merged, ancient}
scenarios := []struct {
name string
commits []*models.Commit
expected string
}{
{
name: "a single commit starts after its parent",
commits: []*models.Commit{second},
expected: "first",
},
{
name: "a range starts after the parent of its oldest commit",
commits: []*models.Commit{third, second},
expected: "first",
},
{
name: "the pull request's first commit starts where the pull request does",
commits: []*models.Commit{first},
},
{
name: "so does a range reaching down to it",
commits: []*models.Commit{third, second, first},
},
{
name: "and so does the first commit of the repository",
commits: []*models.Commit{ancient},
},
{
name: "a parent the panel doesn't list is none of the pull request's",
commits: []*models.Commit{commit("elsewhere", "unlisted", models.StatusPushed)},
},
{
name: "nothing is shown",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pullRequestBaseForCommits(allCommits, s.commits))
})
}
}
+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()
}
-154
View File
@@ -1,154 +0,0 @@
package context
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
deadlock "github.com/sasha-s/go-deadlock"
)
type PatchExplorerContext struct {
*SimpleContext
*SearchTrait
state *patch_exploring.State
viewTrait *ViewTrait
getIncludedLineIndices func() []int
c *ContextCommon
mutex deadlock.Mutex
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
// search result index.
inOnSelectItemCallback bool
}
var (
_ types.IPatchExplorerContext = (*PatchExplorerContext)(nil)
_ types.ISearchableContext = (*PatchExplorerContext)(nil)
)
func NewPatchExplorerContext(
view *gocui.View,
windowName string,
key types.ContextKey,
getIncludedLineIndices func() []int,
c *ContextCommon,
) *PatchExplorerContext {
ctx := &PatchExplorerContext{
state: nil,
viewTrait: NewViewTrait(view),
c: c,
getIncludedLineIndices: getIncludedLineIndices,
SimpleContext: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: view,
WindowName: windowName,
Key: key,
Kind: types.MAIN_CONTEXT,
Focusable: true,
HasSelectableContent: true,
NeedsRerenderOnWidthChange: types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES,
})),
SearchTrait: NewSearchTrait(c),
}
ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged)
return ctx
}
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
return self.state
}
func (self *PatchExplorerContext) SetState(state *patch_exploring.State) {
self.state = state
}
func (self *PatchExplorerContext) GetViewTrait() types.IViewTrait {
return self.viewTrait
}
func (self *PatchExplorerContext) GetIncludedLineIndices() []int {
return self.getIncludedLineIndices()
}
func (self *PatchExplorerContext) RenderAndFocus() {
self.setContent()
self.FocusSelection()
self.c.Render()
}
func (self *PatchExplorerContext) Render() {
self.setContent()
self.c.Render()
}
func (self *PatchExplorerContext) setContent() {
self.GetView().SetContent(self.GetContentToRender())
}
func (self *PatchExplorerContext) FocusSelection() {
view := self.GetView()
state := self.GetState()
bufferHeight := view.InnerHeight()
_, origin := view.Origin()
numLines := view.ViewLinesHeight()
newOriginY := state.CalculateOrigin(origin, bufferHeight, numLines)
view.SetOriginY(newOriginY)
startIdx, endIdx := state.SelectedViewRange()
// As far as the view is concerned, we are always selecting a range
view.SetRangeSelectStart(startIdx)
view.SetCursorY(endIdx - newOriginY)
if !self.inOnSelectItemCallback {
view.SetNearestSearchPosition()
}
}
func (self *PatchExplorerContext) GetContentToRender() string {
if self.GetState() == nil {
return ""
}
return self.GetState().RenderForLineIndices(self.GetIncludedLineIndices())
}
func (self *PatchExplorerContext) NavigateTo(selectedLineIdx int) {
self.GetState().SetLineSelectMode()
self.GetState().SelectLine(selectedLineIdx)
self.RenderAndFocus()
}
func (self *PatchExplorerContext) GetMutex() *deadlock.Mutex {
return &self.mutex
}
func (self *PatchExplorerContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return nil
}
func (self *PatchExplorerContext) OnSearchSelect(selectedLineIdx int) {
self.GetMutex().Lock()
defer self.GetMutex().Unlock()
self.inOnSelectItemCallback = true
self.NavigateTo(selectedLineIdx)
self.inOnSelectItemCallback = false
}
func (self *PatchExplorerContext) OnViewWidthChanged() {
if state := self.GetState(); state != nil {
state.OnViewWidthChanged(self.GetView())
self.setContent()
self.RenderAndFocus()
}
}
+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 },
-42
View File
@@ -41,48 +41,6 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Suggestions: NewSuggestionsContext(c),
Normal: NewMainContext(c.Views().Main, "main", NORMAL_MAIN_CONTEXT_KEY, c),
NormalSecondary: NewMainContext(c.Views().Secondary, "secondary", NORMAL_SECONDARY_CONTEXT_KEY, c),
Staging: NewPatchExplorerContext(
c.Views().Staging,
"main",
STAGING_MAIN_CONTEXT_KEY,
func() []int { return nil },
c,
),
StagingSecondary: NewPatchExplorerContext(
c.Views().StagingSecondary,
"secondary",
STAGING_SECONDARY_CONTEXT_KEY,
func() []int { return nil },
c,
),
CustomPatchBuilder: NewPatchExplorerContext(
c.Views().PatchBuilding,
"main",
PATCH_BUILDING_MAIN_CONTEXT_KEY,
func() []int {
file := commitFilesContext.GetSelectedFile()
if file == nil {
return nil
}
includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
c.Log.Error(err)
return nil
}
return includedLineIndices
},
c,
),
CustomPatchBuilderSecondary: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: c.Views().PatchBuildingSecondary,
WindowName: "secondary",
Key: PATCH_BUILDING_SECONDARY_CONTEXT_KEY,
Focusable: false,
}),
),
MergeConflicts: NewMergeConflictsContext(
c,
),
+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 {
+25 -3
View File
@@ -21,11 +21,33 @@ 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)
_ types.PullRequestDiffContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// BranchForPullRequest returns the branch this panel was entered from, whose commits it
// shows. The panel is also entered from a tag, a remote branch and the reflog, none of
// which a pull request is made from.
func (self *SubCommitsContext) BranchForPullRequest() string {
if branch, ok := self.GetRef().(*models.Branch); ok {
return branch.Name
}
return ""
}
func (self *SubCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
selectedCommits, _, _ := self.GetSelectedItems()
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+7 -2
View File
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
+4 -37
View File
@@ -52,8 +52,7 @@ func (gui *Gui) resetHelpersAndControllers() {
gpgHelper := helpers.NewGpgHelper(helperCommon)
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
stagingHelper := helpers.NewStagingHelper(helperCommon)
customPatchHelper := helpers.NewCustomPatchHelper(helperCommon)
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
searchHelper := helpers.NewSearchHelper(helperCommon)
@@ -61,14 +60,12 @@ func (gui *Gui) resetHelpersAndControllers() {
helperCommon,
refsHelper,
rebaseHelper,
patchBuildingHelper,
stagingHelper,
mergeConflictsHelper,
worktreeHelper,
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -78,7 +75,7 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper := helpers.NewModeHelper(
helperCommon,
diffHelper,
patchBuildingHelper,
customPatchHelper,
cherryPickHelper,
rebaseHelper,
bisectHelper,
@@ -92,8 +89,7 @@ func (gui *Gui) resetHelpersAndControllers() {
gui.helpers = &helpers.Helpers{
Refs: refsHelper,
Host: helpers.NewHostHelper(helperCommon),
PatchBuilding: patchBuildingHelper,
Staging: stagingHelper,
CustomPatch: customPatchHelper,
Bisect: bisectHelper,
Suggestions: suggestionsHelper,
Files: helpers.NewFilesHelper(helperCommon),
@@ -178,18 +174,13 @@ 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)
stashController := controllers.NewStashController(common)
commitFilesController := controllers.NewCommitFilesController(common)
patchExplorerControllerFactory := controllers.NewPatchExplorerControllerFactory(common)
stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false)
stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true)
mainViewController := controllers.NewMainViewController(common, gui.State.Contexts.Normal, gui.State.Contexts.NormalSecondary)
secondaryViewController := controllers.NewMainViewController(common, gui.State.Contexts.NormalSecondary, gui.State.Contexts.Normal)
patchBuildingController := controllers.NewPatchBuildingController(common)
snakeController := controllers.NewSnakeController(common)
reflogCommitsController := controllers.NewReflogCommitsController(common)
subCommitsController := controllers.NewSubCommitsController(common)
@@ -286,28 +277,6 @@ func (gui *Gui) resetHelpersAndControllers() {
)
// TODO: add scroll controllers for main panels (need to bring some more functionality across for that e.g. reading more from the currently displayed git command)
controllers.AttachControllers(gui.State.Contexts.Staging,
stagingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.Staging),
verticalScrollControllerFactory.Create(gui.State.Contexts.Staging),
)
controllers.AttachControllers(gui.State.Contexts.StagingSecondary,
stagingSecondaryController,
patchExplorerControllerFactory.Create(gui.State.Contexts.StagingSecondary),
verticalScrollControllerFactory.Create(gui.State.Contexts.StagingSecondary),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilder,
patchBuildingController,
patchExplorerControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilder),
)
controllers.AttachControllers(gui.State.Contexts.CustomPatchBuilderSecondary,
verticalScrollControllerFactory.Create(gui.State.Contexts.CustomPatchBuilderSecondary),
)
controllers.AttachControllers(gui.State.Contexts.MergeConflicts,
mergeConflictsController,
)
@@ -315,13 +284,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
}
+4 -8
View File
@@ -208,7 +208,7 @@ func (self *BranchesController) GetOnRenderToMain() func() {
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
task = rendererTask
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
rendererTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
rendererTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
@@ -462,7 +462,7 @@ func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *mode
func (self *BranchesController) getPullRequestURL() (string, error) {
branch := self.context().GetSelected()
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
if pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name); ok {
return pr.Url, nil
}
@@ -855,15 +855,11 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
}
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
return self.c.Helpers().Host.NoPullRequestDisabledReason(branch.Name)
}
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
if !ok {
// Should be guarded against by the DisabledReason check, but be defensive in case
// PullRequestsMap was updated concurrently by a background refresh
+461
View File
@@ -0,0 +1,461 @@
package controllers
import (
"fmt"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// CommitDiffActions implements what a panel showing a commit's diff offers on that diff
// in the focused main view. Five panels do: the commit files panel shows the diff of one
// file of a commit, and the commits, sub-commits, stash and reflog panels the whole diff
// of whatever they have selected. They all offer the same thing and differ only in which
// diff they show, so they share this, each saying which diff that is.
type CommitDiffActions struct {
c *ControllerCommon
// The panel this belongs to, and what it is showing the diff of — nil when it has
// nothing selected, and so no diff.
panel types.Context
target func() *commitDiffTarget
}
// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it
// belongs to a commit lazygit may rewrite.
type commitDiffTarget struct {
from string
to string
canRebase bool
}
var _ types.FocusedMainViewActions = &CommitDiffActions{}
func NewCommitDiffActions(
c *ControllerCommon, panel types.Context, target func() *commitDiffTarget,
) *CommitDiffActions {
return &CommitDiffActions{c: c, panel: panel, target: target}
}
// PlainDiff hands out the diff the asking pane is showing, for the given files — the
// commit's diff as in the main view, only without the commit's message and stat above it,
// or the diff the custom patch is previewed as, whose lines are the patch's own rather
// than the commit's.
//
// The patch's own diff is handed out whole: it is only ever as big as the patch, and it
// names its files under the trees the patch was materialized into rather than under the
// paths asked for.
func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
if self.showsCustomPatch(pane) {
return self.customPatchDiff()
}
target := self.target()
if target == nil {
return ""
}
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
}
// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the
// diff behind what the pane previewing the patch shows, in which the lines shown there
// can be found again.
func (self *CommitDiffActions) customPatchDiff() string {
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
if treesDir == "" {
return ""
}
// An error means the two trees differ, as they do for any patch with something in
// it. We are after the diff itself either way.
diff, _ := self.c.Git().Diff.
CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain).
RunWithOutput()
return diff
}
// PrimaryAction takes the selected lines into the custom patch being built from this
// diff, or back out of it when the first of them is already in — the same toggling the
// commit files panel does to a whole file at a time.
//
// The commit is not touched, so the diff stays as it is: what changes is the patch
// beside it, and which of its lines are marked as being in that patch.
func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
// In the pane showing the patch, the lines are the patch's own, so there they only
// come back out of it.
if self.showsCustomPatch(pane) {
return self.removePatchLines(pane, firstLineIdx, lastLineIdx)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
target := self.target()
if target == nil {
return nil
}
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
from, reverse := self.patchEndpoints(target)
// A patch is built from one diff, so building from another one means giving up the
// patch there is — which the user is asked about, as entering the patch builder asks.
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
patchBuilder.Reset()
}
if !patchBuilder.Active() {
patchBuilder.Start(from, target.to, reverse, target.canRebase)
}
if err := self.togglePatchLines(lines); err != nil {
return err
}
// Taking the last line back out ends the patch rather than leaving an empty
// one, so that the pane previewing it and the marks over the diff go with it.
if patchBuilder.IsEmpty() {
patchBuilder.Reset()
}
// The diff on screen is the one the marks belong to, so they can be brought up
// to date at once rather than waiting for the render below.
self.c.Helpers().DiffLine.RefreshInclusionGutter()
// The selection moves on past the lines just toggled, to the next change of
// the diff — which is still there, a toggle leaving the diff as it was, so
// hold input back until it has moved: a second press meanwhile would toggle
// the same lines straight back.
self.c.GocuiGui().BeginBlockingEvents()
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, len(lines),
self.c.GocuiGui().EndBlockingEvents)
// The panel's own render, which is all that is needed: the marks over the diff
// and the patch previewed beside it have changed, while the commit has not.
self.c.PostRefreshUpdate(self.panel)
return nil
},
})
}
// removePatchLines takes the selected lines of the custom patch out of it. The primary
// action does this in the pane showing the patch: everything shown there is in the patch
// already, so there is nothing else it could mean.
//
// A line of the patch is named by its position among its file's changes, counted in the
// diff the patch is shown as. That position is the same one the line has among the
// changes the patch holds for that file. Line numbers would not do: a patch that leaves
// an earlier addition out numbers everything after it differently from the commit's diff.
func (self *CommitDiffActions) removePatchLines(
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
) error {
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
files := self.filesInDiff()
for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) {
filename := self.patchBuilderPath(path)
if filename == "" {
continue
}
included := patchBuilder.IncludedChangeLineIndices(filename)
indices := []int{}
for _, ordinal := range ordinals {
if ordinal < len(included) {
indices = append(indices, included[ordinal])
}
}
if len(indices) == 0 {
continue
}
if err := patchBuilder.RemoveFileLineRange(filename, files.previousPath(filename), indices); err != nil {
return err
}
}
// Taking the last line out ends the patch rather than leaving an empty one, as it does
// in the diff beside this pane.
if patchBuilder.IsEmpty() {
patchBuilder.Reset()
}
self.c.Helpers().DiffLine.RefreshInclusionGutter()
// The lines are gone from the patch, so the selection carries on from where they were,
// as unstaging leaves it. Input is held until it has moved, so that a second press acts
// on the patch as it now is.
self.c.GocuiGui().BeginBlockingEvents()
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, 0,
self.c.GocuiGui().EndBlockingEvents)
self.c.PostRefreshUpdate(self.panel)
return nil
}
// DiscardSelection takes the selected lines out of the commit they are part of, by
// building a patch of exactly those lines and removing that patch from the commit. It is
// a rebase, so a later commit that touches the same lines can conflict with it.
//
// The patch it needs is its own, so a patch being built is given up first — which the
// prompt says, there being no way to get it back.
func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
target := self.target()
if target == nil {
return nil
}
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
if len(lines) == 0 {
return nil
}
commitIndex := self.indexOfTargetCommit(target)
if commitIndex == -1 {
return nil
}
patchBuilder := self.c.Git().Patch.PatchBuilder
prompt := lo.Ternary(patchBuilder.IsEmpty(),
self.c.Tr.DiscardLinesFromCommitPrompt,
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
from, reverse := self.patchEndpoints(target)
patchBuilder.Reset()
patchBuilder.Start(from, target.to, reverse, target.canRebase)
if err := self.togglePatchLines(lines); err != nil {
return err
}
if patchBuilder.IsEmpty() {
return nil
}
// The rebase runs on a worker, which may not read the model, so the commits
// it rewrites are taken here.
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
Message: self.c.Tr.RebasingStatus,
HideWorkingTreeState: true,
}, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
})
},
})
return nil
}
// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the
// commit: doing so rewrites it, which is only ours to do for a commit of the branch we
// are on, and not while a rebase is already under way. In the pane previewing the custom
// patch there is nothing to discard from — the lines there are the patch's, and space
// takes them back out of it.
func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason {
if self.showsCustomPatch(pane) {
return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true}
}
target := self.target()
if target == nil || !target.canRebase {
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
}
if self.c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return &types.DisabledReason{
Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView),
ShowErrorInPanel: true,
}
}
return nil
}
// PatchInclusion says which lines of the commit's diff are in the custom patch being
// built from it. nil when there is no such patch: none is being built at all, or the one
// being built is of another diff, whose lines are not these however alike they look.
func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
patchBuilder := self.c.Git().Patch.PatchBuilder
target := self.target()
if !patchBuilder.Active() || target == nil {
return nil
}
from, reverse := self.patchEndpoints(target)
if patchBuilder.NewPatchRequired(from, target.to, reverse) {
return nil
}
// Which lines of a file are in the patch is asked of the patch builder per file, and
// a diff can span many, so each is asked about when a line of it first comes up.
includedByPath := map[string]*set.Set[patch.LineIdentity]{}
return func(info types.DiffLineInfo) bool {
path := self.patchBuilderPath(info.Path)
if path == "" {
return false
}
included, asked := includedByPath[path]
if !asked {
included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path))
includedByPath[path] = included
}
return included.Includes(info.PatchLineIdentity())
}
}
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
// out of it. The first line of the selection decides which of the two happens, once for
// the whole selection: pointing at a line that is already in the patch takes the whole
// selection out of it, as toggling a selection of files in the commit files panel does.
func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error {
patchBuilder := self.c.Git().Patch.PatchBuilder
// The files the selection covers, in the order the diff shows them, and per file the
// lines of it that are selected: a patch is built a file at a time, while a selection
// can span several of them.
paths := []string{}
linesByPath := map[string][]patch.LineIdentity{}
for _, line := range lines {
path := self.patchBuilderPath(line.Path)
if path == "" {
continue
}
if _, seen := linesByPath[path]; !seen {
paths = append(paths, path)
}
linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity())
}
if len(paths) == 0 {
return nil
}
files := self.filesInDiff()
indicesByPath := map[string][]int{}
wholeFileByPath := map[string]bool{}
for _, path := range paths {
indices, everyChange, err := patchBuilder.PatchLineIndicesForLines(
path, files.previousPath(path), linesByPath[path])
if err != nil {
return err
}
indicesByPath[path] = indices
// Selecting every change of a file the commit adds or deletes is selecting the
// file: what the commit did to it is not something its content lines can carry,
// so a patch of those alone would move the content and leave the file behind.
wholeFileByPath[path] = everyChange && files.isWholeFileOperation(path)
}
included, err := patchBuilder.GetFileIncLineIndices(paths[0], files.previousPath(paths[0]))
if err != nil {
return err
}
removing := len(indicesByPath[paths[0]]) > 0 && lo.Contains(included, indicesByPath[paths[0]][0])
for _, path := range paths {
if len(indicesByPath[path]) == 0 {
continue
}
previousPath := files.previousPath(path)
var err error
switch {
case wholeFileByPath[path] && removing:
err = patchBuilder.RemoveFile(path, previousPath)
case wholeFileByPath[path]:
err = patchBuilder.AddFileWhole(path, previousPath)
case removing:
err = patchBuilder.RemoveFileLineRange(path, previousPath, indicesByPath[path])
default:
err = patchBuilder.AddFileLineRange(path, previousPath, indicesByPath[path])
}
if err != nil {
return err
}
}
return nil
}
// commitDiffFiles records what the diff a patch is being built from says about each of
// the files it covers, by the path the diff shows them under.
type commitDiffFiles map[string]*models.CommitFile
// previousPath says what a file of the diff was called before, and is empty for one
// that wasn't renamed. A renamed file's diff only comes out as a rename when git is
// asked about both of its paths, and its lines are numbered in the file under its old
// name, so the patch builder has to be told the old path along with them.
func (self commitDiffFiles) previousPath(path string) string {
if file, ok := self[path]; ok {
return file.PreviousPath
}
return ""
}
// isWholeFileOperation reports whether what the commit did to this file is something
// its diff's content lines don't say: creating it or deleting it, which the file
// header carries and a patch built from lines alone would leave out.
func (self commitDiffFiles) isWholeFileOperation(path string) bool {
file, ok := self[path]
return ok && (file.Added() || file.Deleted())
}
// filesInDiff asks git which files the diff a patch is being built from covers, and
// what it does to each.
func (self *CommitDiffActions) filesInDiff() commitDiffFiles {
target := self.target()
if target == nil {
return nil
}
from, reverse := self.patchEndpoints(target)
files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse)
if err != nil {
return nil
}
return lo.SliceToMap(files, func(file *models.CommitFile) (string, *models.CommitFile) {
return file.Path, file
})
}
// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends
// of the diff shown, except in diffing mode, where what is shown is a diff against
// another ref, possibly the other way around.
func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) {
return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from)
}
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
func (self *CommitDiffActions) patchBuilderPath(path string) string {
return repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
}
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it
// isn't one of them, in which case there is nothing we can rewrite.
func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int {
return lo.IndexOf(
lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }),
target.to)
}
// showsCustomPatch reports whether the given main pane is the one previewing the custom
// patch being built, rather than the commit's diff — which for a commit's diff is always
// the lower one.
func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool {
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
}
+30 -46
View File
@@ -23,6 +23,9 @@ type CommitFilesController struct {
baseController
*ListControllerTrait[*filetree.CommitFileNode]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *CommitDiffActions
}
var _ types.IController = &CommitFilesController{}
@@ -30,7 +33,7 @@ var _ types.IController = &CommitFilesController{}
func NewCommitFilesController(
c *ControllerCommon,
) *CommitFilesController {
return &CommitFilesController{
controller := &CommitFilesController{
baseController: baseController{},
c: c,
ListControllerTrait: NewListControllerTrait(
@@ -40,6 +43,18 @@ func NewCommitFilesController(
c.Contexts().CommitFiles.GetSelectedItems,
),
}
controller.diffActions = NewCommitDiffActions(c, c.Contexts().CommitFiles, controller.diffTarget)
return controller
}
// diffTarget is the commit whose files this panel is showing. Its main view shows the
// diff of that commit.
func (self *CommitFilesController) diffTarget() *commitDiffTarget {
if self.context().GetRef() == nil && self.context().GetRefRange() == nil {
return nil
}
from, to := self.context().GetFromAndToForDiff()
return &commitDiffTarget{from: from, to: to, canRebase: self.context().GetCanRebase()}
}
func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -109,8 +124,8 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
Handler: self.withItem(self.enter),
GetDisabledReason: self.require(self.singleItemSelected()),
Description: self.c.Tr.EnterCommitFile,
Tooltip: self.c.Tr.EnterCommitFileTooltip,
Description: self.c.Tr.FocusCommitFileDiff,
Tooltip: self.c.Tr.FocusCommitFileDiffTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView),
@@ -175,9 +190,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 +207,15 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
}
}
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
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
@@ -537,41 +557,15 @@ func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (strin
}
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
}
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
if node.File == nil {
return self.handleToggleCommitFileDirCollapsed(node)
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
}
return focusMainView(self.c, self.context(), -1)
}
from, to, reverse := self.currentFromToReverseForPatchBuilding()
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
Title: self.c.Tr.DiscardPatch,
Prompt: self.c.Tr.DiscardPatchConfirm,
HandleConfirm: func() error {
if mustDiscardPatch {
self.c.Git().Patch.PatchBuilder.Reset()
}
if !self.c.Git().Patch.PatchBuilder.Active() {
if err := self.startPatchBuilder(); err != nil {
return err
}
}
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
},
})
func (self *CommitFilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(self.enter)
}
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
@@ -606,16 +600,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())
@@ -1,11 +1,9 @@
package controllers
import (
"errors"
"fmt"
"math"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -51,10 +49,6 @@ func (self *ContextLinesController) Context() types.Context {
}
func (self *ContextLinesController) Increase() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 {
self.c.UserConfig().Git.DiffContextSize++
}
@@ -62,10 +56,6 @@ func (self *ContextLinesController) Increase() error {
}
func (self *ContextLinesController) Decrease() error {
if err := self.checkCanChangeContext(); err != nil {
return err
}
if self.c.UserConfig().Git.DiffContextSize > 0 {
self.c.UserConfig().Git.DiffContextSize--
}
@@ -76,22 +66,11 @@ func (self *ContextLinesController) applyChange() error {
self.c.Toast(fmt.Sprintf(self.c.Tr.DiffContextSizeChanged, self.c.UserConfig().Git.DiffContextSize))
currentContext := self.c.Context().CurrentSide()
switch currentContext.GetKey() {
// we make an exception for our staging and patch building contexts because they actually need to refresh their state afterwards.
case context.PATCH_BUILDING_MAIN_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.PATCH_BUILDING}})
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
default:
currentContext.HandleRenderToMain()
}
return nil
}
func (self *ContextLinesController) checkCanChangeContext() error {
if self.c.Git().Patch.PatchBuilder.Active() {
return errors.New(self.c.Tr.CantChangeContextSizeError)
}
// 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
}
@@ -30,7 +30,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
{
Label: self.c.Tr.ResetPatch,
Tooltip: self.c.Tr.ResetPatchTooltip,
OnPress: self.c.Helpers().PatchBuilding.Reset,
OnPress: self.c.Helpers().CustomPatch.Reset,
Keys: menuKey('c'),
},
{
@@ -123,15 +123,7 @@ func (self *CustomPatchOptionsMenuAction) getPatchCommitIndex() int {
return -1
}
func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessary() {
if self.c.Context().Current().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() {
self.c.Helpers().PatchBuilding.Escape()
}
}
func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
@@ -142,8 +134,6 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
}
func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
@@ -155,8 +145,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() erro
}
func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error {
self.returnFocusFromPatchExplorerIfNecessary()
mustStash := self.c.Helpers().WorkingTree.IsWorkingTreeDirtyExceptSubmodules()
return self.c.ConfirmIf(mustStash, types.ConfirmOpts{
Title: self.c.Tr.MustStashTitle,
@@ -174,8 +162,6 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
@@ -209,8 +195,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error {
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
@@ -244,8 +228,6 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e
}
func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
self.returnFocusFromPatchExplorerIfNecessary()
affectedUnstagedFiles := self.getAffectedUnstagedFiles()
mustStageFiles := len(affectedUnstagedFiles) > 0
+43
View File
@@ -0,0 +1,43 @@
package controllers
import (
"strings"
"github.com/samber/lo"
)
// Removes '+' or '-' from the beginning of each line in the diff string, except
// when both '+' and '-' lines are present, or diff header lines, in which case
// the diff is returned unchanged. This is useful for copying parts of diffs to
// the clipboard in order to paste them into code.
func dropDiffPrefix(diff string) string {
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
const (
PLUS int = iota
MINUS
CONTEXT
OTHER
)
linesByType := lo.GroupBy(lines, func(line string) int {
switch {
case strings.HasPrefix(line, "+"):
return PLUS
case strings.HasPrefix(line, "-"):
return MINUS
case strings.HasPrefix(line, " "):
return CONTEXT
}
return OTHER
})
hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 }
keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS))
if keepPrefix {
return diff
}
return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "")
}
+12
View File
@@ -2,6 +2,7 @@ package controllers
import (
"path"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
@@ -130,3 +131,14 @@ func isInDir(path string, dir string) bool {
// "." is the root item, which contains every file
return dir == "." || strings.HasPrefix(path, dir+"/")
}
// repoRelativePath turns the absolute path a diff line carries into the one git speaks
// of the file: relative to the worktree, with forward slashes. It is "" for a path that
// is no file of this repo, which a diff renderer's own naming of a line can produce.
func repoRelativePath(worktreePath string, absolutePath string) string {
relativePath, err := filepath.Rel(worktreePath, absolutePath)
if err != nil || strings.HasPrefix(relativePath, "..") {
return ""
}
return filepath.ToSlash(relativePath)
}
+44 -45
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,56 +370,52 @@ 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) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
})
func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
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
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.enter
}
// if we are dealing with a status for which there is no key in this map,
@@ -692,7 +692,7 @@ func (self *FilesController) getSelectedFile() *models.File {
}
func (self *FilesController) enter() error {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
return self.enterFile(-1)
}
func (self *FilesController) collapseAll() error {
@@ -711,7 +711,10 @@ func (self *FilesController) expandAll() error {
return nil
}
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
// enterFile focuses the diff of the selected file, which is where the commands that
// act on its lines live. clickedViewLineIdx is the row of the diff a click landed on,
// or -1 when the diff wasn't clicked.
func (self *FilesController) enterFile(clickedViewLineIdx int) error {
node := self.context().GetSelected()
if node == nil {
return nil
@@ -737,11 +740,7 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
return self.switchToMerge()
}
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
self.c.Context().Push(context, opts)
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
return nil
return focusMainView(self.c, self.context(), clickedViewLineIdx)
}
// conflictResolutionHint formats a conflict description for the main view,
@@ -1532,7 +1531,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
}
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
return self.enterFile(opts.Y)
}
func (self *FilesController) fetch() error {
+61
View File
@@ -3,6 +3,7 @@ package controllers
import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -74,6 +75,14 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
Description: self.c.Tr.CycleDiffRenderersReverse,
Tooltip: self.c.Tr.CycleDiffRenderersReverseTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.JumpToFile),
Handler: opts.Guards.NoPopupPanel(self.jumpToFileInDiff),
Description: self.c.Tr.JumpToFileInDiff,
DescriptionFunc: self.jumpToFileInDiffDescription,
Tooltip: self.c.Tr.JumpToFileInDiffTooltip,
OpensMenu: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
@@ -206,6 +215,58 @@ func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
return nil
}
// jumpToFileInDiff offers the files of the diff the main section is showing in a menu,
// and scrolls that pane to the file picked. The panel the user is in keeps the focus;
// they are reading the diff from there, and the next commit or file to read is picked
// there too.
func (self *GlobalController) jumpToFileInDiff() error {
pane := self.diffPane()
if pane == nil {
return nil
}
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(pane, self.c.Tr.JumpToFileInDiff)
}
// jumpToFileInDiffDescription qualifies the command's description so that it is listed
// only where it applies. A command with no description is left out of the keybindings
// menu.
//
// It doesn't apply where the main section is showing content that is no diff of the
// panel's — a branch's commit log, the status dashboard, a message. Nor does it while
// the focus is in one of the panes, which bind the key themselves; the menu would
// otherwise offer it twice there, once for the pane and once among the global keys.
//
// 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 *GlobalController) jumpToFileInDiffDescription() string {
_, focusIsInAPane := self.c.Context().Current().(*context.MainContext)
if focusIsInAPane || self.diffPane() == nil {
return ""
}
return self.c.Tr.JumpToFileInDiff
}
// diffPane returns the pane of the main section showing the diff of the panel the user
// is in, and nil when neither of them is showing one. A pane is cleared as it is
// emptied, so a pane that says it is showing a diff is showing one. Its window also has
// to be showing the pane. Resolving a conflicted file puts the merge conflicts view
// there instead, and the pane behind it goes on holding the diff it last rendered.
//
// Where both panes show a diff — the unstaged and staged sides of a file — the answer
// is the upper one, the pane the keys for scrolling the section act on.
func (self *GlobalController) diffPane() *context.MainContext {
for _, pane := range []*context.MainContext{
self.c.Contexts().Normal, self.c.Contexts().NormalSecondary,
} {
onScreen := self.c.Helpers().Window.GetContextForWindow(pane.GetWindowName()) == pane
if onScreen && pane.ContentIsDiff() {
return pane
}
}
return nil
}
func (self *GlobalController) createOptionsMenu() error {
return (&OptionsMenuAction{c: self.c}).Call()
}
@@ -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()
}
@@ -0,0 +1,25 @@
package helpers
import "github.com/jesseduffield/lazygit/pkg/gui/types"
type CustomPatchHelper struct {
c *HelperCommon
}
func NewCustomPatchHelper(c *HelperCommon) *CustomPatchHelper {
return &CustomPatchHelper{c: c}
}
func (self *CustomPatchHelper) Reset() error {
self.c.Git().Patch.PatchBuilder.Reset()
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
})
// Render again so that the pane that was previewing the patch goes with it. The
// panel asked to do that is the side panel rather than whichever context has the
// focus. Both main panes are rendered by the panel beneath them, so a reset from
// within the focused main view has to go through that panel too.
self.c.PostRefreshUpdate(self.c.Context().CurrentSide())
return nil
}
+34 -12
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,
@@ -205,7 +227,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error {
// AdjustLineNumber is used to adjust a line number in the diff that's currently
// being viewed, so that it corresponds to the line number in the actual working
// copy state of the file. It is used when clicking on a delta hyperlink in a
// diff, or when pressing `e` in the staging or patch building panels. It works
// diff, or when pressing `e` in a focused diff. It works
// by getting a diff of what's being viewed in the main view against the working
// copy, and then using that diff to adjust the line number.
// path is the file path of the file being viewed
@@ -216,7 +238,7 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error {
func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname string) int {
switch viewname {
case "main", "patchBuilding":
case "main":
if diffableContext, ok := self.c.Context().CurrentSide().(types.DiffableContext); ok {
ref := diffableContext.RefForAdjustingLineNumberInDiff()
if len(ref) != 0 {
@@ -227,7 +249,7 @@ func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname s
// unstaged changes view of the Files panel; no need to adjust line
// numbers in this case
case "secondary", "stagingSecondary":
case "secondary":
return self.adjustLineNumber(linenumber, "--", path)
}
@@ -0,0 +1,70 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// OpenJumpToFileMenu offers the files of the diff the given pane is showing in a menu,
// so that one of them can be gone to directly rather than by stepping through the diff
// a file at a time. Picking one goes to where that file's diff begins, the way stepping
// to it with next-file does: the selection moves there in a focused pane, and an
// unfocused one scrolls the file to the top.
//
// The diff is read to the end before the menu is built: a file below the part of it that
// has been read so far is in neither the list nor the view, and reaching the far end of
// a long diff is what the menu is for.
func (self *DiffLineHelper) OpenJumpToFileMenu(pane types.DiffPaneContext, title string) error {
manager := self.c.GetViewBufferManagerForView(pane.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error { return self.showJumpToFileMenu(pane, title) })
})
return nil
}
// showJumpToFileMenu offers the diff's files by the paths git names them by. Each item
// names its file rather than the row that file begins at, so that a diff re-rendered
// while the menu is up is jumped into at the row the file begins at now.
//
// A menu offering the one file of a single-file diff would be a menu with nothing to
// choose, so it says what it found instead. It says it here rather than as the key's
// disabled reason because how many files there are is only known once the diff has been
// read to the end, which is too much to do for every keypress that asks whether a key
// applies.
func (self *DiffLineHelper) showJumpToFileMenu(pane types.DiffPaneContext, title string) error {
view := pane.GetView()
files := self.FilesInDiff(view)
if len(files) == 0 {
return nil
}
if len(files) == 1 {
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + self.c.Tr.OnlyOneFileInDiff)
return nil
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
menuItems := lo.Map(files, func(path string, _ int) *types.MenuItem {
label := repoRelativePath(worktreePath, path)
if label == "" {
label = path
}
return &types.MenuItem{
Label: label,
OnPress: func() error {
if target, ok := self.StartOfFileInDiff(view, path); ok {
self.PlaceNavigationTarget(pane, target, true)
}
return nil
},
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: title,
Items: menuItems,
FilterAsYouType: true,
})
}
+108 -13
View File
@@ -9,6 +9,11 @@ import (
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 {
@@ -32,45 +37,135 @@ func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
// 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 types.DiffLineInfo{}, false
return nil, false
}
contents := view.DiffLineContents()
if bufferLineIdx >= len(contents) {
return types.DiffLineInfo{}, false
return nil, false
}
if renderingStatesDiffLines(contents) {
// A row can carry more than one record, when the rendering puts two diff
// lines on it; the first one is the row's identity, and the leftmost record
// is the one a reader would call the row's own.
if metadata := contents[bufferLineIdx].Metadata; len(metadata) > 0 {
if parsed, ok := parseDiffLineMetadata(metadata[0]); ok {
return self.diffLineInfo(parsed), true
}
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
return self.inRepoTerms(view, identities), true
}
return types.DiffLineInfo{}, false
return nil, false
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
if !ok {
return types.DiffLineInfo{}, false
return nil, false
}
return self.diffLineInfo(parsed), true
return self.inRepoTerms(view, []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(self.c.Git().RepoPaths.WorktreePath(), path)
path = filepath.Join(worktreePath, path)
}
return types.DiffLineInfo{
@@ -125,6 +125,46 @@ func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
})
}
// 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
@@ -420,3 +420,61 @@ func TestRenderingStatesDiffLines(t *testing.T) {
})
}
}
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,180 @@
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 selected in view, 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.DiffLinesInViewRange(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 {
firstRow, ok := view.BufferLineForViewLine(first)
if !ok {
return ""
}
lastRow, ok := view.BufferLineForViewLine(last)
if !ok {
return ""
}
startOfDiff := slices.IndexFunc(self.filePaths(view), func(path string) bool { return path != "" })
if startOfDiff == -1 {
return ""
}
lastRow = min(lastRow, startOfDiff-1)
if lastRow < firstRow {
return ""
}
return strings.Join(view.BufferLines()[firstRow:lastRow+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 {
if relPath := repoRelativePath(worktreePath, path); relPath != "" {
relPaths = append(relPaths, relPath)
}
}
return relPaths
}
// repoRelativePath turns one such path into the repo-relative one, and is "" for a path
// that lies outside the worktree.
func repoRelativePath(worktreePath string, path string) string {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
return ""
}
return filepath.ToSlash(relPath)
}
@@ -0,0 +1,503 @@
package helpers
import (
"path/filepath"
"strings"
"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. They are all built on the identities recovered in
// diff_line_helper.go, which is where the answering stops and the recovering starts.
// DiffLinesInViewRange returns the identity of every diff line shown by the rows in
// the inclusive view-line range [first, last] of view's rendered diff, in display
// order. Rows whose identity can't be recovered are left out, as are the wrapped
// segments of a row already counted.
//
// 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) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
infos := []types.DiffLineInfo{}
previousBufferLine := -1
for viewLine := first; viewLine <= last; viewLine++ {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) {
continue
}
previousBufferLine = bufferLine
infos = append(infos, identities[bufferLine]...)
}
return self.inRepoTerms(view, infos)
}
// ChangeLineOrdinals says, for each of the given change lines, which of its file's
// changes it is in the given diff — its place among them, counted from the top of the
// file — keyed by file. Lines the diff doesn't have are left out.
//
// It is how a line is named in something built out of a diff rather than being that diff:
// the custom patch holds the lines it was given in the order the file has them, so a
// place among a file's changes is a line of the patch.
func (self *DiffLineHelper) ChangeLineOrdinals(
diff string, infos []types.DiffLineInfo,
) map[string][]int {
ordinals := map[patchLine]int{}
counts := map[string]int{}
for _, parsed := range parseAllDiffLinesFromBuffer(strings.Split(diff, "\n")) {
if !parsed.ok {
continue
}
info := self.diffLineInfo(parsed.parsed)
if !info.IsChange() {
continue
}
ordinals[patchLineOf(info)] = counts[info.Path]
counts[info.Path]++
}
ordinalsByPath := map[string][]int{}
for _, info := range infos {
if ordinal, ok := ordinals[patchLineOf(info)]; ok {
ordinalsByPath[info.Path] = append(ordinalsByPath[info.Path], ordinal)
}
}
return ordinalsByPath
}
// inRepoTerms brings the paths of lines recovered from a view into the repo's terms.
//
// They are in them already for a diff of the repo's own files. The pane previewing the
// custom patch, though, shows a diff of the two trees the patch was materialized into: a
// diff renderer states the path it was handed there, which is under the tree's own name,
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
// prefixes and so needs nothing.
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
toRepoTerms := self.repoTermsMapper(view)
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
info.Path = toRepoTerms(info.Path)
return info
})
}
// repoTermsMapper returns how a path recovered from view is brought into the repo's
// terms, for the callers that have a path rather than a whole identity to bring. The
// mapping is worked out once, per view rather than per path.
func (self *DiffLineHelper) repoTermsMapper(view *gocui.View) func(string) string {
if !self.ShowsCustomPatch(view) {
return func(path string) string { return path }
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
return func(path string) string {
return repoPathOfTreePath(path, treesDir, worktreePath)
}
}
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
// into to the file of the repo it stands for: the path is the tree's name followed by the
// file's own, stated either against the directory holding the trees or against the repo,
// depending on how the renderer that stated it was given it.
func repoPathOfTreePath(path string, treesDir string, worktreePath string) string {
root := worktreePath
if treesDir != "" && strings.HasPrefix(path, treesDir+string(filepath.Separator)) {
root = treesDir
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
return path
}
segments := strings.Split(filepath.ToSlash(relativePath), "/")
if len(segments) > 1 && (segments[0] == "a" || segments[0] == "b") {
relativePath = filepath.Join(segments[1:]...)
}
return filepath.Join(worktreePath, relativePath)
}
// ChangeLinesInViewRange returns the change lines — the additions and deletions —
// among the diff lines shown by the rows in the inclusive view-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) ChangeLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
return lo.Filter(self.DiffLinesInViewRange(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 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)
}
// FilesInDiff lists the files of view's (possibly multi-file) rendered diff, in the
// order it shows them, by the paths of the repo's files. It is what a menu offering to
// jump between them is built from; jumping to one of them goes to StartOfFileInDiff.
func (self *DiffLineHelper) FilesInDiff(view *gocui.View) []string {
return lo.Map(fileStarts(self.filePaths(view)),
func(start diffFileStart, _ int) string { return start.path })
}
// StartOfFileInDiff returns the view line the given file's section of view's rendered
// diff begins at. That is the row file navigation lands on, so jumping to a file from a
// menu and stepping to it with next-file land in the same place. ok is false for a file
// the diff doesn't show, e.g. because it was re-rendered since the file was listed.
func (self *DiffLineHelper) StartOfFileInDiff(view *gocui.View, path string) (int, bool) {
start, ok := lo.Find(fileStarts(self.filePaths(view)), func(start diffFileStart) bool {
return start.path == path
})
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(start.row)
}
// filePaths resolves view's rendered diff to the path each buffer line belongs to, in
// the repo's terms, and empty for a row whose identity couldn't be recovered. Naming the
// files the way the rest of the queries name them means a row of the custom patch's
// preview belongs to the repo's file rather than to the copy of it in the tree the patch
// was materialized into, so that both halves of a change belong to the same file however
// the diff renderer states them.
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
resolved := self.resolveDiffLines(view.DiffLineContents())
toRepoTerms := self.repoTermsMapper(view)
paths := make([]string, len(resolved))
for i, row := range resolved {
if row.ok {
paths[i] = toRepoTerms(row.info.Path)
}
}
return paths
}
// diffFileStart is where one file of a diff begins: the path of the file, and the row
// of the diff its section starts at.
type diffFileStart struct {
path string
row int
}
// fileStarts finds, in a diff whose lines carry the file path they belong to (empty for
// a row no backend could place), where each file of it begins, in the order the diff
// shows them.
//
// A file is identified by its path, and the rows showing it are consecutive, so a path
// differing from the one before it begins a file. Rows carrying no path are passed over:
// 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 a file begins at its
// header wherever the source says so (a parseable buffer, or a renderer that tags its
// headers), and at its first content line otherwise, which is an accepted degradation.
func fileStarts(paths []string) []diffFileStart {
starts := []diffFileStart{}
previousPath := ""
for row, path := range paths {
if path == "" || path == previousPath {
continue
}
previousPath = path
starts = append(starts, diffFileStart{path: path, row: row})
}
return starts
}
// fileStart returns where the file adjacent to `from` in the given direction begins —
// the row file navigation lands on. It is the pure index arithmetic behind AdjacentFile.
// ok is false at the first or last file of the diff.
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
starts := fileStarts(paths)
_, anchor, ok := lo.FindIndexOf(starts, func(start diffFileStart) bool {
return start.path == anchorPath
})
if !ok {
return 0, false
}
target := anchor + lo.Ternary(forward, 1, -1)
if target < 0 || target >= len(starts) {
return 0, false
}
return starts[target].row, 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,139 @@
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 TestFileStarts(t *testing.T) {
scenarios := []struct {
name string
paths []string
expected []diffFileStart
}{
{
name: "a parseable diff begins each file at its header",
paths: []string{"a", "a", "a", "b", "b"},
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
},
{
name: "a diff whose headers carry no path begins each file at its first content line",
paths: []string{"", "", "a", "a", "", "", "b", "b"},
expected: []diffFileStart{{path: "a", row: 2}, {path: "b", row: 6}},
},
{
name: "an unlocated row within a file doesn't begin another one",
paths: []string{"a", "", "a", "b"},
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
},
{
name: "a diff with no located rows shows no files",
paths: []string{"", ""},
expected: []diffFileStart{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, fileStarts(s.paths))
})
}
}
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,607 @@
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 one at the given view line — that line's place in the sequence of changes. ok is
// false when the view line belongs to no row of the content.
//
// 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, viewLine int) (int, bool) {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return 0, false
}
ordinal := 0
for i, row := range self.resolveDiffLines(view.DiffLineContents()) {
if i >= bufferLine {
break
}
if row.ok && row.info.IsChange() {
ordinal++
}
}
return ordinal, true
}
// 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}
}
}
// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the
// place of the one just acted on, once the changed diff has re-rendered. Call it with
// the pane acted in, the pane the work carries on in, and the first line of the
// selection, 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.
//
// advanceBy moves on by that many changes past the place remembered, for an action that
// leaves the diff as it was: lines taken into a custom patch are still in the commit's
// diff, so the place remembered is still the line acted on, and carrying on means going
// past the lines just dealt with rather than staying on them.
//
// done, which may be nil, 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) RevealSelectionAfterAction(
source types.DiffPaneContext, target types.DiffPaneContext, firstLineIdx int, advanceBy int, done func(),
) {
ordinal, ok := self.ChangeLineOrdinal(source.GetView(), firstLineIdx)
if !ok {
if done != nil {
done()
}
return
}
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)
self.c.Context().UpdateSelectionHighlights()
}
self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) {
if selectHunk {
self.SelectChangeBlock(target, viewLine, true)
return
}
targetView.CancelRangeSelect()
self.ShowSelectionAtLine(targetView, viewLine, true)
}, done)
}
@@ -0,0 +1,260 @@
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, where a jump
// leaves it, and how it is widened to a whole change block. All three 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)
}
// PlaceNavigationTarget moves the pane's selection to the row a jump found, bringing
// it on screen if it isn't already. With no selection to move — a pane that isn't
// focused, or one showing a diff with nothing selectable in it — the row goes to the
// top of the view instead, that being all a jump can do there.
//
// 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 *DiffLineHelper) PlaceNavigationTarget(
pane types.DiffPaneContext, target int, alignTop bool,
) {
view := pane.GetView()
if !view.Highlight {
view.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(pane, 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(pane)
if pane.DiffSelectState().Mode == types.DiffSelectModeHunk {
self.SelectChangeBlock(pane, target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.ShowSelectionAtLine(view, 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 *DiffLineHelper) scrollTargetToTop(pane types.DiffPaneContext, target int) {
view := pane.GetView()
originY, height := pane.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// CollapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *DiffLineHelper) CollapseNonStickyRange(pane types.DiffPaneContext) {
sel := pane.DiffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
pane.GetView().CancelRangeSelect()
}
}
// 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.UseHunkModeInDiffView {
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.UseHunkModeInDiffView &&
!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)
}
// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which
// say which of its lines are in the custom patch being built from it.
//
// They are shown while the focused main view holds the focus — either of its panes, so
// that moving between the diff and the patch previewed beside it doesn't make them come
// and go — and only over a diff a patch is being built from: a patch built from some
// other commit says nothing about the lines of this one.
//
// Call it whenever either of those can have changed: as a pane's content settles, when
// the focus arrives or leaves, and when the patch itself changes.
func (self *DiffLineHelper) RefreshInclusionGutter() {
view := self.c.Contexts().Normal.GetView()
included := self.patchInclusion()
if included == nil {
view.SetInclusionGutter(false, nil)
return
}
resolved := self.resolveDiffLines(view.DiffLineContents())
marks := make([]bool, len(resolved))
showsChanges := false
for i, row := range resolved {
if !row.ok || !row.info.IsChange() {
continue
}
showsChanges = true
marks[i] = included(row.info)
}
// Nothing to mark and nowhere to mark it: the pane is showing a message rather than
// a diff, or a diff with nothing in it.
if !showsChanges {
view.SetInclusionGutter(false, nil)
return
}
view.SetInclusionGutter(true, marks)
}
// patchInclusion asks the panel whose diff the focused main view is showing which of
// that diff's lines are in the custom patch being built from it, and answers nil where
// there is no such patch — including when the focus is elsewhere, the marks being an
// affordance of the focused view.
func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool {
if !self.mainViewIsFocused() {
return nil
}
// The panel beneath is found from the pane that holds the focus, which is not always
// the one the diff is in: moving to the pane beside it takes the other off the stack.
sidePanel := self.c.Context().NextInStack(self.c.Context().CurrentStatic())
if sidePanel == nil {
return nil
}
actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions)
if !ok {
return nil
}
return actions.PatchInclusion()
}
// ShowsCustomPatch reports whether the given view is the one previewing the custom patch
// being built, which is the lower pane while a patch is being built from the diff in the
// upper one.
func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool {
return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil
}
+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 -4
View File
@@ -28,8 +28,7 @@ type Helpers struct {
MergeConflicts *MergeConflictsHelper
CherryPick *CherryPickHelper
Host *HostHelper
PatchBuilding *PatchBuildingHelper
Staging *StagingHelper
CustomPatch *CustomPatchHelper
GPG *GpgHelper
Upstream *UpstreamHelper
AmendHelper *AmendHelper
@@ -68,8 +67,7 @@ func NewStubHelpers() *Helpers {
MergeConflicts: &MergeConflictsHelper{},
CherryPick: &CherryPickHelper{},
Host: &HostHelper{},
PatchBuilding: &PatchBuildingHelper{},
Staging: &StagingHelper{},
CustomPatch: &CustomPatchHelper{},
GPG: &GpgHelper{},
Upstream: &UpstreamHelper{},
AmendHelper: &AmendHelper{},
+22 -1
View File
@@ -2,9 +2,12 @@ package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// this helper just wraps our hosting_service package
// this helper wraps our hosting_service package, and answers what we know about the
// repo's pull requests on it
type HostHelper struct {
c *HelperCommon
@@ -18,6 +21,24 @@ func NewHostHelper(
}
}
// PullRequestForBranch returns the pull request the given local branch is the head of,
// and false where it has none. That covers knowing of no pull requests at all: the repo
// may not be on GitHub, or the answer may not have arrived yet.
func (self *HostHelper) PullRequestForBranch(branchName string) (*models.GithubPullRequest, bool) {
pr, ok := self.c.Model().PullRequestsMap[branchName]
return pr, ok
}
// NoPullRequestDisabledReason disables a command that acts on a branch's pull request
// while that branch has none.
func (self *HostHelper) NoPullRequestDisabledReason(branchName string) *types.DisabledReason {
if _, ok := self.PullRequestForBranch(branchName); !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
}
func (self *HostHelper) GetPullRequestURL(from string, to string) (string, error) {
mgr, err := self.getHostingServiceMgr()
if err != nil {
+5 -5
View File
@@ -14,7 +14,7 @@ type ModeHelper struct {
c *HelperCommon
diffHelper *DiffHelper
patchBuildingHelper *PatchBuildingHelper
customPatchHelper *CustomPatchHelper
cherryPickHelper *CherryPickHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
bisectHelper *BisectHelper
@@ -24,7 +24,7 @@ type ModeHelper struct {
func NewModeHelper(
c *HelperCommon,
diffHelper *DiffHelper,
patchBuildingHelper *PatchBuildingHelper,
customPatchHelper *CustomPatchHelper,
cherryPickHelper *CherryPickHelper,
mergeAndRebaseHelper *MergeAndRebaseHelper,
bisectHelper *BisectHelper,
@@ -32,7 +32,7 @@ func NewModeHelper(
return &ModeHelper{
c: c,
diffHelper: diffHelper,
patchBuildingHelper: patchBuildingHelper,
customPatchHelper: customPatchHelper,
cherryPickHelper: cherryPickHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
bisectHelper: bisectHelper,
@@ -71,9 +71,9 @@ func (self *ModeHelper) Statuses() []ModeStatus {
return self.withResetButton(self.c.Tr.BuildingPatch, style.FgYellow.SetBold())
},
CancelLabel: func() string {
return self.c.Tr.ExitCustomPatchBuilder
return self.c.Tr.ResetCustomPatch
},
Reset: self.patchBuildingHelper.Reset,
Reset: self.customPatchHelper.Reset,
},
{
IsActive: self.c.Modes().Filtering.Active,
@@ -1,115 +0,0 @@
package helpers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type PatchBuildingHelper struct {
c *HelperCommon
}
func NewPatchBuildingHelper(
c *HelperCommon,
) *PatchBuildingHelper {
return &PatchBuildingHelper{
c: c,
}
}
func (self *PatchBuildingHelper) ShowHunkStagingHint() {
if !self.c.AppState.DidShowHunkStagingHint && self.c.UserConfig().Gui.UseHunkModeInStagingView {
self.c.AppState.DidShowHunkStagingHint = true
self.c.SaveAppStateAndLogError()
message := fmt.Sprintf(self.c.Tr.HunkStagingHint, self.c.UserConfig().Keybinding.Main.ToggleSelectHunk)
self.c.Confirm(types.ConfirmOpts{
Prompt: message,
})
}
}
// takes us from the patch building panel back to the commit files panel
func (self *PatchBuildingHelper) Escape() {
self.c.Context().Pop()
}
// kills the custom patch and returns us back to the commit files panel if needed
func (self *PatchBuildingHelper) Reset() error {
self.c.Git().Patch.PatchBuilder.Reset()
if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
self.Escape()
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.COMMIT_FILES},
})
// refreshing the current context so that the secondary panel is hidden if necessary.
self.c.PostRefreshUpdate(self.c.Context().Current())
return nil
}
func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
selectedLineIdx := -1
if opts.ClickedWindowName == "main" {
selectedLineIdx = opts.ClickedViewLineIdx
}
if !self.c.Git().Patch.PatchBuilder.Active() {
self.Escape()
return
}
// get diff from commit file that's currently selected
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return
}
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)
if err != nil {
return
}
secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{
Filename: file.Path,
PreviousPath: file.PreviousPath,
Plain: false,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
})
context := self.c.Contexts().CustomPatchBuilder
oldState := context.GetState()
state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView)
context.SetState(state)
if state == nil {
self.Escape()
return
}
mainContent := context.GetContentToRender()
self.c.Contexts().CustomPatchBuilder.FocusSelection()
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().PatchBuilding,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(mainContent),
Title: self.c.Tr.Patch,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryDiff),
Title: self.c.Tr.CustomPatch,
},
})
}
+19 -43
View File
@@ -30,8 +30,6 @@ type RefreshHelper struct {
c *HelperCommon
refsHelper *RefsHelper
mergeAndRebaseHelper *MergeAndRebaseHelper
patchBuildingHelper *PatchBuildingHelper
stagingHelper *StagingHelper
mergeConflictsHelper *MergeConflictsHelper
worktreeHelper *WorktreeHelper
searchHelper *SearchHelper
@@ -62,8 +60,6 @@ func NewRefreshHelper(
c *HelperCommon,
refsHelper *RefsHelper,
mergeAndRebaseHelper *MergeAndRebaseHelper,
patchBuildingHelper *PatchBuildingHelper,
stagingHelper *StagingHelper,
mergeConflictsHelper *MergeConflictsHelper,
worktreeHelper *WorktreeHelper,
searchHelper *SearchHelper,
@@ -72,8 +68,6 @@ func NewRefreshHelper(
c: c,
refsHelper: refsHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
patchBuildingHelper: patchBuildingHelper,
stagingHelper: stagingHelper,
mergeConflictsHelper: mergeConflictsHelper,
worktreeHelper: worktreeHelper,
searchHelper: searchHelper,
@@ -248,8 +242,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
var scopeSet *set.Set[types.RefreshableView]
if len(options.Scope) == 0 {
// not refreshing staging/patch-building unless explicitly requested because we only need
// to refresh those while focused.
scopeSet = set.NewFromSlice([]types.RefreshableView{
types.COMMITS,
types.BRANCHES,
@@ -261,7 +253,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
types.PULL_REQUESTS,
})
} else {
@@ -516,37 +507,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
})
}
if scopeSet.Includes(types.STAGING) {
refresh("staging", func() {
fileWg.Wait()
// Bounce onto the UI thread so this runs after the files
// scope's model-update bounce — RefreshStagingPanel reads
// Model.Files (via Files.GetSelected) and would otherwise
// see the pre-refresh model. Guard on the generation so a
// repo switch mid-refresh drops it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.PATCH_BUILDING) {
refresh("patch building", func() {
// Bounce onto the UI thread, like the staging panel above:
// RefreshPatchBuildingPanel reads the commit-files selection and
// sets the patch view's origin, neither of which may run off the UI
// thread. Guard on the generation so a repo switch mid-refresh drops
// it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() {
// Bounce onto the UI thread, like the staging and patch-building
// panels above: RefreshMergeState reads the current context and
// Bounce onto the UI thread: RefreshMergeState reads the current context and
// renders (or escapes) the merge-conflicts view, none of which may
// run off the UI thread.
self.onUIThreadUnlessRepoChanged(env, func() {
@@ -589,7 +552,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
})
}
@@ -675,8 +639,6 @@ func getScopeNames(scopes []types.RefreshableView) []string {
types.WORKTREES: "worktrees",
types.STATUS: "status",
types.BISECT_INFO: "bisect",
types.STAGING: "staging",
types.PATCH_BUILDING: "patchBuilding",
types.MERGE_CONFLICTS: "mergeConflicts",
types.COMMIT_FILES: "commitFiles",
types.PULL_REQUESTS: "pullRequests",
@@ -1085,17 +1047,31 @@ type capturedCommitFilesState struct {
from string
to string
reverse bool
// Whether there is a commit to load the files of at all. The panel is only ever
// pointed at one by being entered, and a patch can now be built from a commit's diff
// without that — after which anything that refreshes the panel would otherwise be
// asking for the files of nothing.
hasCommit bool
}
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
// an immutable snapshot. It must run on the UI thread.
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
commitFilesContext := self.c.Contexts().CommitFiles
if commitFilesContext.GetRef() == nil && commitFilesContext.GetRefRange() == nil {
return capturedCommitFilesState{}
}
from, to := commitFilesContext.GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
return capturedCommitFilesState{from: from, to: to, reverse: reverse, hasCommit: true}
}
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
if !captured.hasCommit {
return nil
}
files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse)
if err != nil {
return err
@@ -50,7 +50,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
types.REFLOG,
types.WORKTREES,
types.BISECT_INFO,
types.STAGING,
}
if options.RefreshPullRequests {
scope = append(scope, types.PULL_REQUESTS)
@@ -1,127 +0,0 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type StagingHelper struct {
c *HelperCommon
}
func NewStagingHelper(
c *HelperCommon,
) *StagingHelper {
return &StagingHelper{
c: c,
}
}
// NOTE: used from outside this file
func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
secondaryFocused := self.secondaryStagingFocused()
mainFocused := self.mainStagingFocused()
// this method could be called when the staging panel is not being used,
// in which case we don't want to do anything.
if !mainFocused && !secondaryFocused {
return
}
mainSelectedLineIdx := -1
secondarySelectedLineIdx := -1
if focusOpts.ClickedViewLineIdx > 0 {
if secondaryFocused {
secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx
} else {
mainSelectedLineIdx = focusOpts.ClickedViewLineIdx
}
}
mainContext := self.c.Contexts().Staging
secondaryContext := self.c.Contexts().StagingSecondary
var file *models.File
node := self.c.Contexts().Files.GetSelected()
if node != nil {
file = node.File
}
if file == nil || (!file.HasUnstagedChanges && !file.HasStagedChanges) {
self.handleStagingEscape()
return
}
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false)
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true)
// grabbing locks here and releasing before we finish the function
// because pushing say the secondary context could mean entering this function
// again, and we don't want to have a deadlock
mainContext.GetMutex().Lock()
secondaryContext.GetMutex().Lock()
hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView
mainContext.SetState(
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode),
)
secondaryContext.SetState(
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode),
)
mainState := mainContext.GetState()
secondaryState := secondaryContext.GetState()
mainContent := mainContext.GetContentToRender()
secondaryContent := secondaryContext.GetContentToRender()
mainContext.GetMutex().Unlock()
secondaryContext.GetMutex().Unlock()
if mainState == nil && secondaryState == nil {
self.handleStagingEscape()
return
}
if mainState == nil && !secondaryFocused {
self.c.Context().Push(secondaryContext, focusOpts)
return
}
if secondaryState == nil && secondaryFocused {
self.c.Context().Push(mainContext, focusOpts)
return
}
if secondaryFocused {
self.c.Contexts().StagingSecondary.FocusSelection()
} else {
self.c.Contexts().Staging.FocusSelection()
}
self.c.RenderToMainViews(types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Staging,
Main: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(mainContent),
Title: self.c.Tr.UnstagedChanges,
},
Secondary: &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(secondaryContent),
Title: self.c.Tr.StagedChanges,
},
})
}
func (self *StagingHelper) handleStagingEscape() {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
func (self *StagingHelper) secondaryStagingFocused() bool {
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().StagingSecondary.GetKey()
}
func (self *StagingHelper) mainStagingFocused() bool {
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey()
}
@@ -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",
+23 -14
View File
@@ -667,15 +667,11 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
}
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
branch := self.c.Model().CheckedOutBranch
if _, ok := self.c.Model().PullRequestsMap[branch]; !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
return self.c.Helpers().Host.NoPullRequestDisabledReason(self.c.Model().CheckedOutBranch)
}
func (self *LocalCommitsController) openPRInBrowser() error {
pr, ok := self.c.Model().PullRequestsMap[self.c.Model().CheckedOutBranch]
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.c.Model().CheckedOutBranch)
if !ok {
// Should be guarded against by the DisabledReason check, but be defensive in case
// PullRequestsMap was updated concurrently by a background refresh
@@ -722,17 +718,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
}
}
// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane
// beside the diff it is being built from, as a diff of the two trees the patch is
// materialized into. This way it is shown by whatever renders the rest of the diffs,
// and its lines can be pointed at and taken back out of the patch.
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
if c.Git().Patch.PatchBuilder.Active() {
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
return &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(patch),
Title: c.Tr.CustomPatch,
}
if !c.Git().Patch.PatchBuilder.Active() {
return nil
}
return nil
// A render of the same patch reuses the trees; only a change to the patch writes them
// again.
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
c.Log.Error(err)
}
// The same mode as the diff beside it: both panes of the pair have to agree about
// whether what they show can be acted on.
mode := c.Helpers().DiffLine.MainViewDiffMode()
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode)
return &types.ViewUpdateOpts{
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
Title: c.Tr.CustomPatch,
}
}
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
package controllers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestGithubPullRequestLineURL(t *testing.T) {
const prURL = "https://github.com/jesseduffield/lazygit/pull/1234"
const commitHash = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
// The anchor names the file by the SHA-256 of its repo-relative path, taken over
// exactly those bytes: no leading slash, no trailing newline, forward slashes.
const fileHash = "067980d6efc4249367ceb61b0d93a00bca100a0ddb6d4a72b6dbb0eb9d3825cc" // "dir/file1"
scenarios := []struct {
name string
path string
info types.DiffLineInfo
expected string
}{
{
name: "an added line is on the right side of the diff",
path: "dir/file1",
info: types.DiffLineInfo{Type: types.DiffLineAdded, NewLine: 12},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R12",
},
{
name: "a deleted line is on the left side, at the line it sat on",
path: "dir/file1",
// A deletion's NewLine is only where it sits in the new version of the
// file; the line it is, is the old one.
info: types.DiffLineInfo{Type: types.DiffLineDeleted, NewLine: 12, OldLine: 34},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "L34",
},
{
name: "a context line is on the right side too",
path: "dir/file1",
info: types.DiffLineInfo{Type: types.DiffLineContext, NewLine: 7, OldLine: 5},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R7",
},
{
name: "a hunk header points at the first line of its hunk",
path: "dir/file1",
info: types.DiffLineInfo{Type: types.DiffLineHunkHeader, NewLine: 20},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R20",
},
{
name: "the header naming a file points at the file alone",
path: "dir/file1",
info: types.DiffLineInfo{Type: types.DiffLineFileHeader},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash,
},
{
name: "a row that is no line of the file points at the file alone",
path: "dir/file1",
info: types.DiffLineInfo{Type: types.DiffLineOther},
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash,
},
{
name: "a file at the root of the repo",
path: "file1",
info: types.DiffLineInfo{Type: types.DiffLineAdded, NewLine: 1},
expected: prURL + "/changes/" + commitHash +
"#diff-c147efcfc2d7ea666a9e4f5187b115c90903f0fc896a56df9a6ef5d8f3fc9f31R1",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, githubPullRequestLineURL(prURL, commitHash, s.path, s.info))
})
}
}
func TestGithubCommitRange(t *testing.T) {
hashPool := &utils.StringPool{}
newest := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newest"})
oldest := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "oldest"})
scenarios := []struct {
name string
commits []*models.Commit
baseHash string
expected string
}{
{
name: "a single commit is named on its own",
commits: []*models.Commit{newest},
baseHash: "parent",
expected: "newest",
},
{
name: "a range is named as the commits it lies between",
commits: []*models.Commit{newest, oldest},
baseHash: "parent",
expected: "parent..newest",
},
{
name: "a range starting where the pull request does lies above BASE",
commits: []*models.Commit{newest, oldest},
expected: "BASE..newest",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, githubCommitRange(s.commits, s.baseHash))
})
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ func (self *OptionsMenuAction) Call() error {
if binding.GetDisabledReason != nil {
disabledReason = binding.GetDisabledReason()
}
tooltip := binding.Tooltip
tooltip := binding.GetTooltip()
if len(binding.Keys) > 1 {
if tooltip != "" {
tooltip += "\n\n"
@@ -1,277 +0,0 @@
package controllers
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type PatchBuildingController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &PatchBuildingController{}
func NewPatchBuildingController(
c *ControllerCommon,
) *PatchBuildingController {
return &PatchBuildingController{
baseController: baseController{},
c: c,
}
}
func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.OpenFile),
Handler: self.OpenFile,
Description: self.c.Tr.OpenFile,
Tooltip: self.c.Tr.OpenFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.EditFile,
Description: self.c.Tr.EditFile,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Select),
Handler: self.ToggleSelectionAndRefresh,
Description: self.c.Tr.ToggleSelectionForPatch,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Remove),
Handler: self.discardSelection,
GetDisabledReason: self.getDisabledReasonForDiscard,
Description: self.c.Tr.RemoveSelectionFromPatch,
Tooltip: self.c.Tr.RemoveSelectionFromPatchTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.Escape,
Description: self.c.Tr.ExitCustomPatchBuilder,
DescriptionFunc: self.EscapeDescription,
DisplayOnScreen: true,
},
}
}
func (self *PatchBuildingController) Context() types.Context {
return self.c.Contexts().CustomPatchBuilder
}
func (self *PatchBuildingController) context() types.IPatchExplorerContext {
return self.c.Contexts().CustomPatchBuilder
}
func (self *PatchBuildingController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *PatchBuildingController) GetOnFocus() func(types.OnFocusOpts) {
return func(opts types.OnFocusOpts) {
// no need to change wrap on the secondary view because it can't be interacted with
self.c.Views().PatchBuilding.Wrap = self.c.UserConfig().Gui.WrapLinesInStagingView
self.c.Helpers().PatchBuilding.RefreshPatchBuildingPanel(opts)
}
}
func (self *PatchBuildingController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(opts types.OnFocusLostOpts) {
self.context().SetState(nil)
self.c.Views().PatchBuilding.Wrap = true
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
}
}
func (self *PatchBuildingController) OpenFile() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
path := self.c.Contexts().CommitFiles.GetSelectedPath()
if path == "" {
return nil
}
return self.c.Helpers().Files.OpenFile(path)
}
func (self *PatchBuildingController) EditFile() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
path := self.c.Contexts().CommitFiles.GetSelectedPath()
if path == "" {
return nil
}
lineNumber := self.context().GetState().CurrentLineNumber()
lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context().GetViewName())
return self.c.Helpers().Files.EditFileAtLine(path, lineNumber)
}
func (self *PatchBuildingController) ToggleSelectionAndRefresh() error {
if err := self.toggleSelection(); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.PATCH_BUILDING, types.COMMIT_FILES},
})
return nil
}
func (self *PatchBuildingController) toggleSelection() error {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
file := self.c.Contexts().CommitFiles.GetSelectedFile()
if file == nil {
return nil
}
state := self.context().GetState()
// Get added/deleted lines in the selected patch range
lineIndicesToToggle := state.LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange()
if len(lineIndicesToToggle) == 0 {
// Only context lines or header lines selected, so nothing to do
return nil
}
includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath)
if err != nil {
return err
}
toggleFunc := self.c.Git().Patch.PatchBuilder.AddFileLineRange
firstSelectedChangeLineIsStaged := lo.Contains(includedLineIndices, lineIndicesToToggle[0])
if firstSelectedChangeLineIsStaged {
toggleFunc = self.c.Git().Patch.PatchBuilder.RemoveFileLineRange
}
// add range of lines to those set for the file
if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil {
// might actually want to return an error here
self.c.Log.Error(err)
}
if state.SelectingRange() {
state.SetLineSelectMode()
}
state.SelectNextStageableLineOfSameIncludedState(self.context().GetIncludedLineIndices(), firstSelectedChangeLineIsStaged)
return nil
}
func (self *PatchBuildingController) getDisabledReasonForDiscard() *types.DisabledReason {
if !self.c.Git().Patch.PatchBuilder.CanRebase {
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
}
if self.c.Git().Status.WorkingTreeState().Any() {
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
}
if self.c.UserConfig().Git.DiffContextSize == 0 {
text := fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
return &types.DisabledReason{Text: text, ShowErrorInPanel: true}
}
return nil
}
func (self *PatchBuildingController) discardSelection() error {
prompt := lo.Ternary(self.c.Git().Patch.PatchBuilder.IsEmpty(),
self.c.Tr.DiscardLinesFromCommitPrompt,
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DiscardLinesFromCommitTitle,
Prompt: prompt,
HandleConfirm: func() error {
return self.discardSelectionFromCommit()
},
})
return nil
}
func (self *PatchBuildingController) discardSelectionFromCommit() error {
// Reset the current patch if there is one.
if !self.c.Git().Patch.PatchBuilder.IsEmpty() {
self.c.Git().Patch.PatchBuilder.Reset()
}
if err := self.toggleSelection(); err != nil {
return err
}
if self.c.Git().Patch.PatchBuilder.IsEmpty() {
return nil
}
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
self.c.Helpers().PatchBuilding.Escape()
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})
})
}
func (self *PatchBuildingController) getPatchCommitIndex() int {
for index, commit := range self.c.Model().Commits {
if commit.Hash() == self.c.Git().Patch.PatchBuilder.To {
return index
}
}
return -1
}
func (self *PatchBuildingController) Escape() error {
context := self.c.Contexts().CustomPatchBuilder
state := context.GetState()
if state.SelectingRange() || state.SelectingHunkEnabledByUser() {
state.SetLineSelectMode()
self.c.PostRefreshUpdate(context)
return nil
}
self.c.Helpers().PatchBuilding.Escape()
return nil
}
func (self *PatchBuildingController) EscapeDescription() string {
context := self.c.Contexts().CustomPatchBuilder
if state := context.GetState(); state != nil {
if state.SelectingRange() {
return self.c.Tr.DismissRangeSelect
}
if state.SelectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
}
return self.c.Tr.ExitCustomPatchBuilder
}
@@ -1,416 +0,0 @@
package controllers
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type PatchExplorerControllerFactory struct {
c *ControllerCommon
}
func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerControllerFactory {
return &PatchExplorerControllerFactory{
c: c,
}
}
func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController {
controller := &PatchExplorerController{
baseController: baseController{},
c: self.c,
context: context,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
self.c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
type PatchExplorerController struct {
baseController
c *ControllerCommon
context types.IPatchExplorerContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
func (self *PatchExplorerController) Context() types.Context {
return self.context
}
func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.PrevItem),
Handler: self.withRenderAndFocus(self.HandlePrevLine),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.NextItem),
Handler: self.withRenderAndFocus(self.HandleNextLine),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
Handler: self.withRenderAndFocus(self.HandlePrevLineRange),
Description: self.c.Tr.RangeSelectUp,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.withRenderAndFocus(self.HandleNextLineRange),
Description: self.c.Tr.RangeSelectDown,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.withRenderAndFocus(self.HandlePrevHunk),
Description: self.c.Tr.PrevHunk,
},
{
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
Handler: self.withRenderAndFocus(self.HandleNextHunk),
Description: self.c.Tr.NextHunk,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.withRenderAndFocus(self.HandleToggleSelectRange),
Description: self.c.Tr.ToggleRangeSelect,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk),
Description: self.c.Tr.ToggleSelectHunk,
DescriptionFunc: func() string {
if state := self.context.GetState(); state != nil && state.SelectingHunk() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
},
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.PrevPage),
Handler: self.withRenderAndFocus(self.HandlePrevPage),
Description: self.c.Tr.PrevPage,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.NextPage),
Handler: self.withRenderAndFocus(self.HandleNextPage),
Description: self.c.Tr.NextPage,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.GotoTop),
Handler: self.withRenderAndFocus(self.HandleGotoTop),
Description: self.c.Tr.GotoTop,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.GotoBottom),
Description: self.c.Tr.GotoBottom,
Handler: self.withRenderAndFocus(self.HandleGotoBottom),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft),
Handler: self.withRenderAndFocus(self.HandleScrollLeft),
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.ScrollRight),
Handler: self.withRenderAndFocus(self.HandleScrollRight),
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.withLock(self.CopySelectedToClipboard),
Description: self.c.Tr.CopySelectedTextToClipboard,
},
}
}
func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: func(opts gocui.ViewMouseBindingOpts) error {
if self.isFocused() {
return self.withRenderAndFocus(self.HandleMouseDown)()
}
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
})
return nil
},
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.handleMouseDrag,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() },
},
}
}
func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error {
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(opts.Y)
self.renderDragSelection()
return nil
})(); err != nil {
return err
}
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *PatchExplorerController) canDragAutoscroll(int) bool {
state := self.context.GetState()
return state != nil && state.SelectingRange()
}
func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool {
if !self.canDragAutoscroll(0) {
return false
}
if err := self.withLock(func() error {
self.context.GetState().DragSelectLine(viewIndex)
self.renderDragSelection()
return nil
})(); err != nil {
return false
}
return true
}
func (self *PatchExplorerController) renderDragSelection() {
view := self.context.GetView()
state := self.context.GetState()
originY := view.OriginY()
startIndex, _ := state.SelectedViewRange()
view.SetRangeSelectStart(startIndex)
view.SetCursorY(state.GetSelectedViewLineIdx() - originY)
self.context.Render()
}
func (self *PatchExplorerController) handleDragRelease() error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
return nil
}
func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
func (self *PatchExplorerController) HandlePrevLine() error {
before := self.context.GetState().GetSelectedViewLineIdx()
self.context.GetState().CycleSelection(false)
after := self.context.GetState().GetSelectedViewLineIdx()
if self.context.GetState().SelectingLine() {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
return nil
}
func (self *PatchExplorerController) HandleNextLine() error {
before := self.context.GetState().GetSelectedViewLineIdx()
self.context.GetState().CycleSelection(true)
after := self.context.GetState().GetSelectedViewLineIdx()
if self.context.GetState().SelectingLine() {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
return nil
}
func (self *PatchExplorerController) HandlePrevLineRange() error {
s := self.context.GetState()
s.CycleRange(false)
return nil
}
func (self *PatchExplorerController) HandleNextLineRange() error {
s := self.context.GetState()
s.CycleRange(true)
return nil
}
func (self *PatchExplorerController) HandlePrevHunk() error {
self.context.GetState().SelectPreviousHunk()
return nil
}
func (self *PatchExplorerController) HandleNextHunk() error {
self.context.GetState().SelectNextHunk()
return nil
}
func (self *PatchExplorerController) HandleToggleSelectRange() error {
self.context.GetState().ToggleStickySelectRange()
return nil
}
func (self *PatchExplorerController) HandleToggleSelectHunk() error {
self.context.GetState().ToggleSelectHunk()
return nil
}
func (self *PatchExplorerController) HandleScrollLeft() error {
self.context.GetViewTrait().ScrollLeft()
return nil
}
func (self *PatchExplorerController) HandleScrollRight() error {
self.context.GetViewTrait().ScrollRight()
return nil
}
func (self *PatchExplorerController) HandlePrevPage() error {
self.context.GetState().AdjustSelectedLineIdx(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *PatchExplorerController) HandleNextPage() error {
self.context.GetState().AdjustSelectedLineIdx(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *PatchExplorerController) HandleGotoTop() error {
self.context.GetState().SelectTop()
return nil
}
func (self *PatchExplorerController) HandleGotoBottom() error {
self.context.GetState().SelectBottom()
return nil
}
func (self *PatchExplorerController) HandleMouseDown() error {
self.context.GetState().SelectNewLineForRange(self.context.GetViewTrait().SelectedLineIdx())
return nil
}
func (self *PatchExplorerController) CopySelectedToClipboard() error {
selected := self.context.GetState().PlainRenderSelected()
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
if err := self.c.OS().CopyToClipboard(dropDiffPrefix(selected)); err != nil {
return err
}
return nil
}
// Removes '+' or '-' from the beginning of each line in the diff string, except
// when both '+' and '-' lines are present, or diff header lines, in which case
// the diff is returned unchanged. This is useful for copying parts of diffs to
// the clipboard in order to paste them into code.
func dropDiffPrefix(diff string) string {
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
const (
PLUS int = iota
MINUS
CONTEXT
OTHER
)
linesByType := lo.GroupBy(lines, func(line string) int {
switch {
case strings.HasPrefix(line, "+"):
return PLUS
case strings.HasPrefix(line, "-"):
return MINUS
case strings.HasPrefix(line, " "):
return CONTEXT
}
return OTHER
})
hasLinesOfType := func(lineType int) bool { return len(linesByType[lineType]) > 0 }
keepPrefix := hasLinesOfType(OTHER) || (hasLinesOfType(PLUS) && hasLinesOfType(MINUS))
if keepPrefix {
return diff
}
return strings.Join(lo.Map(lines, func(line string, _ int) string { return line[1:] + "\n" }), "")
}
func (self *PatchExplorerController) isFocused() bool {
return self.c.Context().Current().GetKey() == self.context.GetKey()
}
func (self *PatchExplorerController) withRenderAndFocus(f func() error) func() error {
return self.withLock(func() error {
if err := f(); err != nil {
return err
}
self.context.RenderAndFocus()
return nil
})
}
func (self *PatchExplorerController) withLock(f func() error) func() error {
return func() error {
self.context.GetMutex().Lock()
defer self.context.GetMutex().Unlock()
if self.context.GetState() == nil {
return nil
}
return f()
}
}

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