Compare commits

..
Author SHA1 Message Date
Stefan Haller e14177523c 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 4fdeb2645b 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot e576b39e26 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 fd4e89ce1b 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot dabf112c8d 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot c3680ebc5b 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 9e5b73b09c 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot e163c59893 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 f937814c8d 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot d510f5ae1b 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot c23253857a 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 8933f8485e 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-10-01 17:28:30 +02:00
Stefan Haller 8a0c28af20 Migrate range select tests to focused main view 2026-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 7d792ecf13 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 4591cdb685 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 6c98bcfb38 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot e59185bdc7 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 115e25e43c 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot c966efe1f3 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 835d15bf53 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 94e5f8ea2d 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 03e1d37492 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 5f9c71bfef 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 6e43038b4b 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 0091757e18 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot d3c234860e 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 0a5d1bce55 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-10-01 17:28:30 +02:00
Stefan HallerandGitHub Copilot 89d94b8989 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 5db5a61f09 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 c397f7c03a 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 e0aa9ad075 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-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 d8fb750910 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 and whenever the patch
itself changes. Working them out as the content settles keeps them right
across a renderer switch, a change of context size, or a walk through
the commits.

They are shown whenever the main view shows the diff the patch is built
from, whether or not it has the focus. The pane beside the diff previews
the patch all the while, and marks that came and went with the focus
would look like a bug while browsing. A diff of any other commit gets
none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:28:30 +02:00
Stefan HallerandClaude Opus 5 d06e25c62b 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 dd35d60b8a 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 1b95be674c 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 cdc47d6512 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 2e6d25300e 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 aeacc20d99 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 5116a5aa75 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-10-01 17:07:53 +02:00
Stefan HallerandClaude Opus 5 2716562139 Edit the selected hunk from the focused main view
The staging view can hand the hunk you are on to an editor and apply what
comes back, which is how you stage something the diff cannot express: half
of a changed line, or a change written differently from either side. The
focused main view has to be able to do the same before that view can go.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Behaviour-preserving.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Nothing installs either of them yet.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:42:10 +02:00
71 changed files with 373 additions and 3543 deletions
-15
View File
@@ -300,18 +300,6 @@ gui:
# NerdFontsVersion is not empty.
showFileIcons: true
# How the commit graph is drawn.
# One of: 'auto' (default) | 'classic' | 'detailed'
# 'detailed' connects the lines to the commit circles, and shows exactly where
# branches fork off and merge. It draws the graph with the git branch drawing
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
# GPU acceleration. Other terminals need a font that contains them, such as
# https://github.com/rbong/flog-symbols.
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
# these symbols (kitty and Ghostty), and 'classic' otherwise.
commitGraphStyle: auto
# Length of author name in (non-expanded) commits view. 2 means show initials
# only.
commitAuthorShortLength: 2
@@ -709,7 +697,6 @@ keybinding:
- "4"
- "5"
focusMainView: "0"
jumpToFile: <ctrl+g>
nextMatch: "n"
prevMatch: "N"
startSearch: /
@@ -951,8 +938,6 @@ It is used, for example, when pasting a commit message into the commit message p
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.
```yaml
@@ -24,7 +24,6 @@ _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. |
@@ -235,8 +234,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -319,8 +316,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -24,7 +24,6 @@ _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> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
@@ -204,8 +203,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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フックなしで変更をコミット | |
@@ -296,8 +293,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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フックなしで変更をコミット | |
@@ -24,7 +24,6 @@ _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. |
@@ -96,8 +95,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -191,8 +188,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -24,7 +24,6 @@ _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. |
@@ -243,8 +242,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -319,8 +316,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -24,7 +24,6 @@ _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. |
@@ -111,8 +110,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -214,8 +211,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -24,7 +24,6 @@ _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. |
@@ -247,8 +246,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -328,8 +325,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 | |
@@ -24,7 +24,6 @@ _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. |
@@ -86,8 +85,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 `` | Закоммитить изменения без предварительного хука коммита | |
@@ -113,8 +110,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 `` | Закоммитить изменения без предварительного хука коммита | |
@@ -24,7 +24,6 @@ _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> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
@@ -282,8 +281,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 `` | 提交变更而无需预先提交钩子 | |
@@ -325,8 +322,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 `` | 提交变更而无需预先提交钩子 | |
@@ -24,7 +24,6 @@ _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> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
@@ -71,8 +70,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 就提交更改 | |
@@ -347,8 +344,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <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 就提交更改 | |
-8
View File
@@ -179,11 +179,6 @@ type GuiConfig struct {
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
ShowFileIcons bool `yaml:"showFileIcons"`
// How the commit graph is drawn.
// One of: 'auto' (default) | 'classic' | 'detailed'
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"`
// Length of author name in (non-expanded) commits view. 2 means show initials only.
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
// Length of author name in expanded commits view. 2 means show initials only.
@@ -516,7 +511,6 @@ 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"`
@@ -930,7 +924,6 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
ShowIcons: false,
NerdFontsVersion: "",
ShowFileIcons: true,
CommitGraphStyle: "auto",
CommitAuthorShortLength: 2,
CommitAuthorLongLength: 17,
CommitHashLength: 8,
@@ -1044,7 +1037,6 @@ 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{"/"},
-4
View File
@@ -30,10 +30,6 @@ func (config *UserConfig) Validate() error {
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
return err
}
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
[]string{"auto", "classic", "detailed"}); err != nil {
return err
}
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
return err
-34
View File
@@ -32,37 +32,3 @@ 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)
}
+5 -22
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 clear status tainted flag
g.flushContentOnly(g.views)
// flushContentOnly should succeed and clear status tainted flag
assert.NoError(t, 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,28 +76,11 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) {
status, _ := setupViews(t, g)
status.SetContent("Fetching |")
g.flushContentOnly(g.views)
assert.NoError(t, 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)
@@ -248,7 +231,7 @@ func TestFlushContentOnly_DoesNotOverdrawHigherZViews(t *testing.T) {
assert.False(t, popup.IsTainted(), "popup should not be tainted")
// flushContentOnly is what spinner ticks ultimately invoke.
g.flushContentOnly(g.views)
assert.NoError(t, g.flushContentOnly(g.views))
assert.Equal(t, "P", cellAt(21, 9),
"popup region must still show popup content after flushContentOnly; "+
@@ -296,7 +279,7 @@ func TestFlushContentOnly_RedrawsTransitivelyOverlappingViews(t *testing.T) {
assert.False(t, b.IsTainted())
assert.False(t, c.IsTainted())
g.flushContentOnly(g.views)
assert.NoError(t, g.flushContentOnly(g.views))
// a redrawn (direct).
assert.Equal(t, "X", cellAt(5, 5), "a should be redrawn (tainted)")
+110 -117
View File
@@ -91,14 +91,6 @@ 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 {
@@ -421,12 +413,13 @@ 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) {
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error {
if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY {
// swallowing error because it's not that big of a deal
return
return nil
}
tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode)
return nil
}
// SetView creates a new view with its top-left corner at (x0, y0)
@@ -743,12 +736,6 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme {
return g.colorScheme
}
// Terminal returns the name and version that the terminal reported at
// startup. Both are empty if it didn't report them.
func (g *Gui) Terminal() (string, string) {
return g.screen.Terminal()
}
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
// the terminal's colors change after startup.
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
@@ -1202,8 +1189,7 @@ func (g *Gui) processEvent() error {
contentOnly = contentOnly && remainingContentOnly
if contentOnly {
g.flushContentOnly(g.views)
return nil
return g.flushContentOnly(g.views)
}
return g.flush()
}
@@ -1304,7 +1290,7 @@ func (g *Gui) onResize() {
}
// drawFrameEdges draws the horizontal and vertical edges of a view.
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
runeH, runeV := '─', '│'
if len(v.FrameRunes) >= 2 {
runeH, runeV = v.FrameRunes[0], v.FrameRunes[1]
@@ -1315,10 +1301,14 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
continue
}
if v.y0 > -1 && v.y0 < g.maxY {
g.SetRune(x, v.y0, runeH, fgColor, bgColor)
if err := g.SetRune(x, v.y0, runeH, fgColor, bgColor); err != nil {
return err
}
}
if v.y1 > -1 && v.y1 < g.maxY {
g.SetRune(x, v.y1, runeH, fgColor, bgColor)
if err := g.SetRune(x, v.y1, runeH, fgColor, bgColor); err != nil {
return err
}
}
}
@@ -1328,14 +1318,19 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
continue
}
if v.x0 > -1 && v.x0 < g.maxX {
g.SetRune(v.x0, y, runeV, fgColor, bgColor)
if err := g.SetRune(v.x0, y, runeV, fgColor, bgColor); err != nil {
return err
}
}
if v.x1 > -1 && v.x1 < g.maxX {
runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, y, runeV)
g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor)
if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
func calcScrollbarRune(
@@ -1435,13 +1430,17 @@ func corner(v *View, directions byte) rune {
}
// drawFrameCorners draws the corners of the view.
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error {
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 {
g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor)
g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor)
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
}
}
return
return nil
}
runeTL, runeTR, runeBL, runeBR := '┌', '┐', '└', '┘'
@@ -1462,15 +1461,18 @@ func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
for _, c := range corners {
if c.x >= 0 && c.y >= 0 && c.x < g.maxX && c.y < g.maxY {
g.SetRune(c.x, c.y, c.ch, fgColor, bgColor)
if err := g.SetRune(c.x, c.y, c.ch, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
// drawTitle draws the title of the view.
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
if v.y0 < 0 || v.y0 >= g.maxY {
return
return nil
}
tabs := v.Tabs
@@ -1506,7 +1508,9 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
x := v.x0 + 2
for _, ch := range prefix {
g.SetRune(x, v.y0, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
for i, ch := range str {
@@ -1529,55 +1533,64 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
currentFgColor &= ^AttrBold
}
}
g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor)
if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
return nil
}
// drawSubtitle draws the subtitle of the view.
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) {
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
if v.y0 < 0 || v.y0 >= g.maxY {
return
return nil
}
start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle)
if start < v.x0 {
return
return nil
}
x := start
for _, ch := range v.Subtitle {
if x >= v.x1 {
break
}
g.SetRune(x, v.y0, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
return err
}
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) {
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.buf.lines) == 0 {
return
return nil
}
message := v.Footer
if v.y1 < 0 || v.y1 >= g.maxY {
return
return nil
}
start := v.x1 - 1 - uniseg.StringWidth(message)
if start < v.x0 {
return
return nil
}
x := start
for _, ch := range message {
if x >= v.x1 {
break
}
g.SetRune(x, v.y1, ch, fgColor, bgColor)
if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil {
return err
}
x += uniseg.StringWidth(string(ch))
}
return nil
}
// flush updates the gui, re-drawing frames and buffers.
@@ -1605,35 +1618,40 @@ func (g *Gui) flush() error {
}
}
for _, v := range g.views {
g.draw(v)
if err := g.draw(v); err != nil {
return err
}
}
Screen.Show()
return nil
}
// Redraws only dirty views and skips the layout pass.
// Redraws only tainted 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 dirty views.
func (g *Gui) flushContentOnly(views []*View) {
// Will also redraw any views that overlap tainted views
func (g *Gui) flushContentOnly(views []*View) error {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
return
return nil
}
for _, v := range viewsToRedrawContentOnly(views) {
g.draw(v)
if err := g.draw(v); err != nil {
return err
}
}
Screen.Show()
return nil
}
func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.NeedsRedraw() && !redrawIndexes.Includes(i) {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
continue
}
@@ -1663,11 +1681,11 @@ func (g *Gui) ForceLayoutAndRedraw() error {
return g.flush()
}
// Redraws only dirty views outside of the normal main
// Redraws only tainted 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) {
g.flushContentOnly(views)
func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
return g.flushContentOnly(views)
}
// hasFocus reports whether a view is drawn as focused. Views that are embedded
@@ -1685,9 +1703,9 @@ func outermostView(v *View) *View {
}
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) {
func (g *Gui) draw(v *View) error {
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
return
return nil
}
if g.Cursor {
@@ -1726,18 +1744,30 @@ func (g *Gui) draw(v *View) {
}
}
g.drawFrameEdges(v, frameColor, bgColor)
g.drawFrameCorners(v, frameColor, bgColor)
if err := g.drawFrameEdges(v, frameColor, bgColor); err != nil {
return err
}
if err := g.drawFrameCorners(v, frameColor, bgColor); err != nil {
return err
}
if v.Title != "" || len(v.Tabs) > 0 {
g.drawTitle(v, fgColor, bgColor)
if err := g.drawTitle(v, fgColor, bgColor); err != nil {
return err
}
}
if v.Subtitle != "" {
g.drawSubtitle(v, fgColor, bgColor)
if err := g.drawSubtitle(v, fgColor, bgColor); err != nil {
return err
}
}
if v.Footer != "" && g.ShowListFooter {
g.drawListFooter(v, fgColor, bgColor)
if err := g.drawListFooter(v, fgColor, bgColor); err != nil {
return err
}
}
}
return nil
}
// onKey manages key-press events. A keybinding handler is called when
@@ -1821,25 +1851,6 @@ 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
@@ -1847,21 +1858,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
break
}
}
// 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 {
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
g.captureMouse(v)
}
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease &&
!gestureIsModified {
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
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
@@ -1899,8 +1900,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if IsMouseKey(ev.Key) {
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
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)
if err != nil {
return err
}
@@ -1932,49 +1934,43 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
return nil
}
// 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) {
// 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 {
if IsMouseScrollKey(key) {
g.lastClick = nil
return
return false
}
// 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
return false
}
g.lastClick = &clickInfo{
clickInfo := &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, handleWhenPopupPanelFocused bool) (bool, error) {
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() &&
ev.Key.KeyName() == binding.Key &&
ev.Key.Mod() == binding.Modifier &&
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
ev.Key.Mod() == binding.Modifier
}
// first pass looks for ones that match the focused view
@@ -2149,9 +2145,6 @@ 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
@@ -1,46 +0,0 @@
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
@@ -1,49 +0,0 @@
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")
}
+1 -12
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 { g.flushContentOnly(g.views); return nil }},
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
}
for _, tc := range tests {
@@ -67,14 +67,3 @@ 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())
}
+3 -11
View File
@@ -209,7 +209,6 @@ const (
var (
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
lastMouseMod tcell.ModMask = tcell.ModNone
dragState = NOT_DRAGGING
lastX = 0
lastY = 0
@@ -378,12 +377,6 @@ 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
@@ -409,8 +402,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
case tcell.ButtonMiddle:
default:
}
mouseMod = Modifier(lastMouseMod)
lastMouseMod = tcell.ModNone
mouseMod = ModNone
lastMouseKey = tcell.ButtonNone
}
default:
@@ -441,10 +433,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
// reaches drag bindings instead of being delivered with the
// default MouseRelease key.
dragState = DRAGGING
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseMod = ModMotion
mouseKey = MouseLeft
case DRAGGING:
mouseMod = Modifier(lastMouseMod) | ModMotion
mouseMod = ModMotion
mouseKey = MouseLeft
}
}
+3 -37
View File
@@ -36,55 +36,21 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
}
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
t.Cleanup(resetMouseState)
resetMouseState()
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
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
+31 -54
View File
@@ -80,27 +80,18 @@ 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 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 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.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
@@ -283,18 +274,11 @@ 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.markViewLinesDirty()
v.tainted = true
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
@@ -809,13 +793,11 @@ 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,
}
@@ -975,10 +957,6 @@ 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 = " "
@@ -1199,7 +1177,7 @@ func (v *View) write(p []byte) {
return
}
v.markViewLinesDirty()
v.tainted = true
// 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)
@@ -1634,7 +1612,7 @@ func (v *View) SwapInOffscreenRender() {
}
v.buf = v.offscreen
v.offscreen = nil
v.markViewLinesDirty()
v.tainted = true
v.clearHover()
}
@@ -1798,12 +1776,6 @@ 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()
@@ -1812,7 +1784,6 @@ func (v *View) draw(isWindowFocused bool) {
if !v.Visible {
return
}
defer func() { v.needsRedraw = false }()
v.clearRunes()
@@ -1937,8 +1908,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 should
// be wrapped afresh.
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
@@ -2332,22 +2303,28 @@ func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
return fgColor | selTextColor&AttrStyleBits
}
// 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()
// 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
}
v.lineFlashY = viewLine
v.needsRedraw = true
}
func (v *View) ClearLineFlash() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.lineFlashY = -1
v.needsRedraw = true
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()
}
func lineWrap(line []cell, columns int) [][]cell {
-26
View File
@@ -1113,32 +1113,6 @@ 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.
+4 -24
View File
@@ -19,36 +19,16 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
_ types.PullRequestDiffContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// 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 },
+4 -72
View File
@@ -12,7 +12,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
@@ -32,70 +31,16 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
_ types.PullRequestDiffContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// 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(
@@ -133,7 +78,6 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
startIdx,
endIdx,
shouldShowGraph(c),
commitGraphSymbolSet(c),
c.Model().BisectInfo,
)
}
@@ -410,18 +354,6 @@ func shouldShowGraph(c *ContextCommon) bool {
return false
}
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
switch c.UserConfig().Gui.CommitGraphStyle {
case "detailed":
return graph.BranchDrawingSymbols
case "auto":
if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) {
return graph.BranchDrawingSymbols
}
}
return graph.BoxDrawingSymbols
}
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
modelToViewIndex func(int) int, searchStr string,
) []gocui.SearchPosition {
@@ -4,12 +4,8 @@ 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"
)
@@ -55,101 +51,3 @@ 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))
})
}
}
+4 -22
View File
@@ -21,33 +21,16 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
_ types.PullRequestDiffContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
// 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 {
@@ -97,7 +80,6 @@ func NewSubCommitsContext(
startIdx,
endIdx,
shouldShowGraph(c),
commitGraphSymbolSet(c),
git_commands.NewNullBisectInfo(),
)
}
+8 -4
View File
@@ -208,7 +208,7 @@ func (self *BranchesController) GetOnRenderToMain() func() {
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
task = rendererTask
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
pr, ok := self.c.Model().PullRequestsMap[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.Helpers().Host.PullRequestForBranch(branch.Name); ok {
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
return pr.Url, nil
}
@@ -855,11 +855,15 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
}
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
return self.c.Helpers().Host.NoPullRequestDisabledReason(branch.Name)
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
}
return nil
}
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
pr, ok := self.c.Model().PullRequestsMap[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
+7 -1
View File
@@ -2,6 +2,8 @@ package controllers
import (
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@@ -441,7 +443,11 @@ func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string,
// 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)
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil || strings.HasPrefix(relativePath, "..") {
return ""
}
return filepath.ToSlash(relativePath)
}
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
-12
View File
@@ -2,7 +2,6 @@ package controllers
import (
"path"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
@@ -131,14 +130,3 @@ 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)
}
-61
View File
@@ -3,7 +3,6 @@ package controllers
import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -75,14 +74,6 @@ 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,
@@ -215,58 +206,6 @@ 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()
}
@@ -1,70 +0,0 @@
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,
})
}
@@ -154,19 +154,11 @@ func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
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)
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
relPaths = append(relPaths, filepath.ToSlash(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)
}
@@ -78,26 +78,16 @@ func (self *DiffLineHelper) ChangeLineOrdinals(
// 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 }
return infos
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
return func(path string) string {
return repoPathOfTreePath(path, treesDir, worktreePath)
}
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
return info
})
}
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
@@ -345,98 +335,62 @@ func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, f
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.
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
// empty for a row whose identity couldn't be recovered.
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
resolved := self.resolveDiffLines(view.DiffLineContents())
toRepoTerms := self.repoTermsMapper(view)
paths := make([]string, len(resolved))
for i, row := range resolved {
if row.ok {
paths[i] = toRepoTerms(row.info.Path)
paths[i] = 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.
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
// a row no backend could place), the first located row of the file adjacent to `from`
// in the given direction — the row file navigation lands on. It is the pure index
// arithmetic behind AdjacentFile.
//
// A file is identified by its path, 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.
// A file is identified by its path, so we look for where the path changes, skipping
// rows that carry none: those are the blank separator rows between files, or the
// header rows of a diff renderer that doesn't state which file its headers belong to.
// So the landing row is the file's header wherever the source says so — a parseable
// buffer, or a renderer that tags its headers — and the file's first content line
// otherwise, which is an accepted degradation.
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
starts := fileStarts(paths)
_, anchor, ok := lo.FindIndexOf(starts, func(start diffFileStart) bool {
return start.path == anchorPath
})
if !ok {
if forward {
for i := from; i < len(paths); i++ {
if paths[i] != "" && paths[i] != anchorPath {
return i, true
}
}
return 0, false
}
target := anchor + lo.Ternary(forward, 1, -1)
if target < 0 || target >= len(starts) {
// Walk back past the current file (its rows and any unlocated ones) to the previous
// file's last located row, then back over that whole file, landing on its first.
i := from
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
i--
}
if i < 0 {
return 0, false
}
return starts[target].row, true
prevPath := paths[i]
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
i--
}
for paths[i] != prevPath {
i++
}
return i, true
}
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
@@ -48,41 +48,6 @@ func TestChangeBlockStart(t *testing.T) {
}
}
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.
@@ -33,21 +33,6 @@ func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
return git_commands.DiffModeRendered
}
// DiffRowsCanBePlaced reports whether the rows of the diff the main view is about to be
// given can be placed in the file they show. git's own diff describes itself, whether it
// is what the user configured or what MainViewDiffMode is about to substitute for a
// rendering that can't be acted on; any other rendering says where its rows belong only
// if it states records for them.
//
// It is what anything that means to go from a row back to the file it shows has to ask
// first: with neither records nor a diff that describes itself, there is nothing to go
// on, and offering the user the way there would be offering nothing.
func (self *DiffLineHelper) DiffRowsCanBePlaced() bool {
return !self.diffNeedsMetadata() ||
self.MainViewDiffMode() == git_commands.DiffModeRaw ||
self.diffRendererEmitsMetadata()
}
// 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.
@@ -7,9 +7,9 @@ import (
"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.
// Putting a selection in the focused main view: where it starts out, and how it is
// widened to a whole change block. Both are answered from what the view is showing,
// as recovered by the queries next door.
// EstablishSelection turns on the focused main view's selection once the view has
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
@@ -63,66 +63,6 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext,
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
@@ -1,339 +0,0 @@
package helpers
import (
"bytes"
"io"
"regexp"
"strings"
"sync/atomic"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// A diff opens with a diffstat naming every file in it, above the diff of each of
// them. Here each of those names is made a link to where that file's diff begins, so
// that a file of a long diff can be gone to by clicking the line that names it.
//
// The names are recognized in the output as it is written to the pane, where they cost
// next to nothing to find. The diffstat is git's own text whichever renderer the diff
// goes through — delta, diff-so-fancy and difftastic all pass it on untouched — and it
// comes first, so the scan for it ends with it.
// DiffStatLinkScheme names a link to a file of the diff the pane is showing, as
// lazygit-edit names one that opens a file in the editor. The link is never handed to
// the terminal — gocui takes the escape sequence out of the content and gives the URL
// back when the cell it covers is clicked — so the path in it needs no escaping.
const DiffStatLinkScheme = "lazygit-diff-file://"
// diffStatEntryPattern matches a line of a diffstat and captures the path it states.
// Such a line holds the name of the file, padded out to the width of the longest, then
// the number of lines it changes (or "Bin" for a binary file) and the graph of them.
//
// The name is captured greedily, so that the separator found is the last one on the
// line rather than one in a file name that contains " | " itself.
var diffStatEntryPattern = regexp.MustCompile(`^ (.*[^ ]) +\| +(?:Bin|\d+)`)
// DiffStatLinkWriter hands a pane's content on to it, turning the file names in the
// diffstat the content opens with into links (see DiffStatLinkScheme).
type DiffStatLinkWriter struct {
writer io.Writer
// Whether the diffstat is still to come, is being written now, or is behind us. It
// is behind us once a line comes that is no entry of it, or that the diff proper
// begins with, and nothing past that is looked at. A name down there names the file
// the reader is already in.
//
// It is atomic because the render is begun on the UI thread while the content of
// it arrives on the goroutine reading the command's output.
state atomic.Int32
}
type diffStatState int32
const (
diffStatToCome diffStatState = iota
inDiffStat
diffStatDone
)
func NewDiffStatLinkWriter(writer io.Writer) *DiffStatLinkWriter {
return &DiffStatLinkWriter{writer: writer}
}
// BeginRender starts a fresh render, whose own diffstat is the one to look for. It is
// called as the render is asked for, before any of it is written.
//
// linkFiles says whether this render is one whose file names lead anywhere: the pane's
// own diff, whose rows can be placed in the files they show. Either is passed through
// untouched without it. Content that is no diff of the panel's — a commit log, a
// message — has no diffstat in it, and a line of one that happens to read like an entry
// of a diffstat names no file to go to. A rendering whose rows nothing can place does
// have the files in it, but nothing to find the one a name stands for with.
func (self *DiffStatLinkWriter) BeginRender(linkFiles bool) {
self.setState(lo.Ternary(linkFiles, diffStatToCome, diffStatDone))
}
func (self *DiffStatLinkWriter) getState() diffStatState {
return diffStatState(self.state.Load())
}
func (self *DiffStatLinkWriter) setState(state diffStatState) {
self.state.Store(int32(state))
}
func (self *DiffStatLinkWriter) Write(p []byte) (int, error) {
linked := self.withFileNameLinked(p)
written, err := self.writer.Write(linked)
if err != nil {
return 0, err
}
if written < len(linked) {
return 0, io.ErrShortWrite
}
// The caller is owed an answer about what it gave us, not about what we passed on.
return len(p), nil
}
// withFileNameLinked returns the given line of the render with the name in it linked,
// where the line is an entry of the diffstat.
func (self *DiffStatLinkWriter) withFileNameLinked(line []byte) []byte {
state := self.getState()
if state == diffStatDone {
return line
}
if beginsTheDiffItself(line) {
// A diffstat that hasn't come by now isn't coming: the pane is showing a diff
// that was asked for without one.
self.setState(diffStatDone)
return line
}
match := diffStatEntry(line)
if match == nil {
if state == inDiffStat {
self.setState(diffStatDone)
}
return line
}
self.setState(inDiffStat)
start, end := match[2], match[3]
// The link states the name as it reads on screen, so that a renderer that colors
// the diffstat doesn't put escape sequences into it.
name := utils.Decolorise(string(line[start:end]))
linked := make([]byte, 0, len(line)+len(name)+32)
linked = append(linked, line[:start]...)
linked = append(linked, style.PrintHyperlink(string(line[start:end]), DiffStatLinkScheme+name)...)
return append(linked, line[end:]...)
}
// diffLineRecordOpener opens an OSC 1717 record, ahead of the version whose fields the
// record states (see parseDiffLineMetadata).
const diffLineRecordOpener = "\x1b]1717;"
// beginsTheDiffItself reports whether the line is one of the diff proper rather than
// one of the diffstat above it: git's own header for a file, or a line a renderer
// states a record about (see statesADiffLine).
func beginsTheDiffItself(line []byte) bool {
return bytes.HasPrefix(line, []byte("diff --")) || statesADiffLine(line)
}
// diffLineRecordKinds are the kinds of record a renderer states about a line of the
// diff itself. A line stating one of them is below the whole diffstat, which is what
// makes them the end of the search for it.
//
// They are listed here rather than read off the parser's table (see
// diffLineTypeFromMetadata), which answers a different question: whether a record can be
// read at all. A kind the protocol gains for something above the diff — one renderer
// stated the commit line — would belong in that table and not in this list, and taking
// the table for this would end the search where the diffstat hasn't even begun. Every
// kind of the protocol is held against this list by a test.
var diffLineRecordKinds = []string{"c", "a", "d", "f", "h"}
// statesADiffLine reports whether the line carries a record in which a diff renderer
// states which line of which file it is rendering. Those records are about the lines of
// the diff, and the diffstat comes before all of them.
//
// The kind the record states has to be one of diffLineRecordKinds. A record of any
// other kind says nothing about where the diffstat ends, so the search goes on past it.
//
// The version the record opens with is passed over rather than read. This asks which
// lines a renderer states records for, and the answer holds whichever version of the
// protocol it speaks. A record with nothing after the version is the handshake a
// renderer announces itself with, which is about no line, so the search goes on past it.
func statesADiffLine(line []byte) bool {
for rest := line; ; {
at := bytes.Index(rest, []byte(diffLineRecordOpener))
if at == -1 {
return false
}
rest = rest[at+len(diffLineRecordOpener):]
// The record reads <version>;<kind>;, and the kind is a single character.
digits := 0
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
digits++
}
kind := digits + 1
if digits == 0 || kind+1 >= len(rest) || rest[digits] != ';' || rest[kind+1] != ';' {
continue
}
if lo.Contains(diffLineRecordKinds, string(rest[kind:kind+1])) {
return true
}
}
}
// diffStatEntry matches line against diffStatEntryPattern, behind the two checks that
// answer for nearly every line of a diff without the pattern being run at all: an entry
// of a diffstat is indented by a space, and holds the separator. The indices it returns
// are into the whole line, whatever the match was made past.
func diffStatEntry(line []byte) []int {
start := handshakeEnd(line)
rest := line[start:]
if len(rest) == 0 || rest[0] != ' ' || bytes.IndexByte(rest, '|') == -1 {
return nil
}
match := diffStatEntryPattern.FindSubmatchIndex(rest)
for i := range match {
if match[i] >= 0 {
match[i] += start
}
}
return match
}
// handshakeEnd returns where the record a renderer announces itself with ends, for a
// line that opens with one, and 0 for every other line.
//
// A renderer writes the handshake before anything else and with no newline after it,
// so it lands at the start of the first line of its output. For a diff with nothing
// above its diffstat — the diff of a range of commits, or of a stash — that is the line
// naming the first file in it, and the entry begins after the record rather than at the
// start of the line.
func handshakeEnd(line []byte) int {
if !bytes.HasPrefix(line, []byte(diffLineRecordOpener)) {
return 0
}
after := len(diffLineRecordOpener)
for after < len(line) && line[after] >= '0' && line[after] <= '9' {
after++
}
// Either terminator ends a record. One that goes on into a field instead is about
// a line of the diff, which is below the whole diffstat and no entry of it.
switch {
case after < len(line) && line[after] == '\x07':
return after + 1
case after+1 < len(line) && line[after] == '\x1b' && line[after+1] == '\\':
return after + 2
}
return 0
}
// JumpToFileNamedInDiffStat goes to the file of the pane's diff that the given diffstat
// entry names, for a click on the link made for that entry. It lands the way picking
// the file from the menu of the diff's files does.
//
// The diff is read to the end first, as it is for that menu. The diffstat is on screen
// only while the view is at the top of the diff, so the file clicked is nearly always
// below the part of it that has been read.
func (self *DiffLineHelper) JumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
manager := self.c.GetViewBufferManagerForView(pane.GetView())
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
self.jumpToFileNamedInDiffStat(pane, entry)
return nil
})
})
}
func (self *DiffLineHelper) jumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
view := pane.GetView()
worktreePath := self.c.Git().RepoPaths.WorktreePath()
files := self.FilesInDiff(view)
names := lo.Map(files, func(file string, _ int) string {
return repoRelativePath(worktreePath, file)
})
index, ok := fileNamedByDiffStatEntry(entry, names)
if !ok {
self.c.ErrorToast(utils.ResolvePlaceholderString(
self.c.Tr.NoFileInDiffNamed, map[string]string{"path": entry}))
return
}
if target, ok := self.StartOfFileInDiff(view, files[index]); ok {
self.PlaceNavigationTarget(pane, target, true)
}
}
// fileNamedByDiffStatEntry returns which of the diff's files a diffstat entry names.
//
// An entry states the path as the diffstat has room for it rather than as git names
// the file. A path too long for the column is cut off on the left behind "...", and a
// rename is compacted to the "{old => new}" form. So the name is looked for among the
// files the diff turned out to hold, whole and then as the end of one, and is taken
// only where it names a single file.
func fileNamedByDiffStatEntry(entry string, paths []string) (int, bool) {
name := renamedTo(strings.TrimSpace(entry))
if index, ok := theOneMatching(paths, func(p string) bool { return p == name }); ok {
return index, true
}
// Where the diffstat cut the path off, what is left is the end of it. The cut is at
// a directory boundary where there is room for one, and inside the file name where
// there isn't.
tail := strings.TrimPrefix(name, "...")
return theOneMatching(paths, func(p string) bool { return strings.HasSuffix(p, tail) })
}
// renamedTo returns the path a diffstat entry for a rename leaves the file at, and the
// entry itself for any other one. A rename states both paths, with whatever they have
// in common written once: "dir/{old => new}/file", or "old => new" where they share
// nothing. The part shared with the old path is gone along with the "{" when the entry
// is cut off on the left, which leaves a path to match the end of.
func renamedTo(entry string) string {
const arrow = " => "
at := strings.Index(entry, arrow)
if at == -1 {
return entry
}
shared := ""
if brace := strings.Index(entry[:at], "{"); brace != -1 {
shared = entry[:brace]
}
renamed := entry[at+len(arrow):]
if closing := strings.Index(renamed, "}"); closing != -1 {
return shared + renamed[:closing] + renamed[closing+1:]
}
return shared + renamed
}
// theOneMatching returns the index of the one element the predicate holds for, and
// false where it holds for none of them or for several.
func theOneMatching(paths []string, matches func(string) bool) (int, bool) {
found := -1
for i, candidate := range paths {
if !matches(candidate) {
continue
}
if found != -1 {
return 0, false
}
found = i
}
return found, found != -1
}
@@ -1,312 +0,0 @@
package helpers
import (
"bytes"
"fmt"
"slices"
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
// link is the line the writer is expected to produce for a diffstat entry: the space
// it is indented by, the name linked, and the rest of the line as it came.
func link(name string, rest string) string {
return " " + style.PrintHyperlink(name, DiffStatLinkScheme+name) + rest
}
// record is the OSC 1717 record a diff renderer speaking the given version of the
// protocol states a line of the given kind with, as it precedes that line in its
// output.
func record(version string, kind string) string {
return fmt.Sprintf("%s%s;%s;;;pkg/gui.go\x1b\\", diffLineRecordOpener, version, kind)
}
// handshake is the record a renderer announces the protocol with: the version it
// speaks, and nothing about any line. Renderers end their records with either
// terminator, so both turn up.
var (
handshake = diffLineRecordOpener + "1\x1b\\"
handshakeBel = diffLineRecordOpener + "1\x07"
)
func TestDiffStatLinkWriter(t *testing.T) {
scenarios := []struct {
name string
linkFiles bool
lines []string
expected []string
}{
{
name: "links the entries of the diffstat, and nothing after it",
linkFiles: true,
lines: []string{
"commit 1234567",
"",
" A commit message",
"",
" pkg/gui.go | 12 ++++++------",
" dir/other.go | 3 ++-",
" 2 files changed, 8 insertions(+), 7 deletions(-)",
"",
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
expected: []string{
"commit 1234567",
"",
" A commit message",
"",
link("pkg/gui.go", " | 12 ++++++------"),
link("dir/other.go", " | 3 ++-"),
" 2 files changed, 8 insertions(+), 7 deletions(-)",
"",
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
},
{
name: "links a binary file, a file that changes nothing, and a name with spaces",
linkFiles: true,
lines: []string{
" logo.png | Bin 0 -> 1234 bytes",
" script.sh | 0",
" my file.txt | 2 +-",
},
expected: []string{
link("logo.png", " | Bin 0 -> 1234 bytes"),
link("script.sh", " | 0"),
link("my file.txt", " | 2 +-"),
},
},
{
name: "stops looking once the diff itself has begun",
linkFiles: true,
lines: []string{
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
expected: []string{
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
},
{
name: "stops looking at the first line of the diff a renderer states",
linkFiles: true,
lines: []string{
" pkg/gui.go | 1 +",
record("1", "c") + " a context line of the diff",
" this/looks/like/a/diff/stat | 2 +",
},
expected: []string{
link("pkg/gui.go", " | 1 +"),
record("1", "c") + " a context line of the diff",
" this/looks/like/a/diff/stat | 2 +",
},
},
{
// A renderer is free to state records about something that is no line of
// the diff, and one has stated the commit line above it. The diffstat is
// below such a record as much as it is below the handshake.
name: "keeps looking past a record of a kind it doesn't know",
linkFiles: true,
lines: []string{
record("1", "C") + "commit 1234567",
" pkg/gui.go | 1 +",
},
expected: []string{
record("1", "C") + "commit 1234567",
link("pkg/gui.go", " | 1 +"),
},
},
{
name: "stops for a record of a protocol version it doesn't read",
linkFiles: true,
lines: []string{
record("7", "f") + "── pkg/gui.go ──",
" this/looks/like/a/diff/stat | 2 +",
},
expected: []string{
record("7", "f") + "── pkg/gui.go ──",
" this/looks/like/a/diff/stat | 2 +",
},
},
{
name: "keeps looking past the handshake, which states no line",
linkFiles: true,
lines: []string{
handshake,
" pkg/gui.go | 1 +",
},
expected: []string{
handshake,
link("pkg/gui.go", " | 1 +"),
},
},
{
// A diff with nothing above its diffstat. The handshake is written with no
// newline after it, so it runs into the entry naming the first file.
name: "links an entry the handshake runs into",
linkFiles: true,
lines: []string{
handshake + " pkg/gui.go | 1 +",
" dir/other.go | 2 +-",
},
expected: []string{
handshake + link("pkg/gui.go", " | 1 +"),
link("dir/other.go", " | 2 +-"),
},
},
{
name: "links an entry a handshake ended with a BEL runs into",
linkFiles: true,
lines: []string{
handshakeBel + " pkg/gui.go | 1 +",
},
expected: []string{
handshakeBel + link("pkg/gui.go", " | 1 +"),
},
},
{
name: "leaves a render whose file names lead nowhere alone",
linkFiles: false,
lines: []string{
" pkg/gui.go | 12 ++++++------",
},
expected: []string{
" pkg/gui.go | 12 ++++++------",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
buffer := &bytes.Buffer{}
writer := NewDiffStatLinkWriter(buffer)
writer.BeginRender(scenario.linkFiles)
for _, line := range scenario.lines {
written, err := writer.Write([]byte(line + "\n"))
assert.NoError(t, err)
// The writer answers for what it was given, not for what it passed on.
assert.Equal(t, len(line)+1, written)
}
assert.Equal(t, strings.Join(scenario.expected, "\n")+"\n", buffer.String())
})
}
}
func TestDiffStatLinkWriterStartsLookingAgainWithEachRender(t *testing.T) {
buffer := &bytes.Buffer{}
writer := NewDiffStatLinkWriter(buffer)
for range 2 {
buffer.Reset()
writer.BeginRender(true)
_, _ = writer.Write([]byte(" pkg/gui.go | 1 +\n"))
_, _ = writer.Write([]byte(" 1 file changed, 1 insertion(+)\n"))
assert.Equal(t, link("pkg/gui.go", " | 1 +")+
"\n 1 file changed, 1 insertion(+)\n", buffer.String())
}
}
func TestFileNamedByDiffStatEntry(t *testing.T) {
paths := []string{
"pkg/gui.go",
"pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
"vendor/github.com/gdamore/tcell/v3/AUTHORS",
"pkg/gocui/AUTHORS",
"renamed.txt",
"a/very/deeply/nested/directory/structure/some_long_file_name.txt",
}
scenarios := []struct {
name string
entry string
expected string
}{
{
name: "a path the diffstat had room for",
entry: "pkg/gui.go",
expected: "pkg/gui.go",
},
{
name: "a path cut off at a directory boundary",
entry: ".../tests/main_view/jump_to_a_file_of_the_diff.go",
expected: "pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
},
{
name: "a path cut off inside the file name",
entry: "..._long_file_name.txt",
expected: "a/very/deeply/nested/directory/structure/some_long_file_name.txt",
},
{
name: "a rename, stated as the part the two paths share",
entry: "vendor/github.com/gdamore/tcell/{v2 => v3}/AUTHORS",
expected: "vendor/github.com/gdamore/tcell/v3/AUTHORS",
},
{
name: "a rename whose shared part was cut off along with the brace",
entry: ".../github.com/jesseduffield => pkg}/gocui/AUTHORS",
expected: "pkg/gocui/AUTHORS",
},
{
name: "a rename of paths that share nothing",
entry: "original.txt => renamed.txt",
expected: "renamed.txt",
},
{
name: "a name of no file of the diff",
entry: "pkg/nowhere.go",
expected: "",
},
{
name: "a name several files of the diff end with",
entry: "AUTHORS",
expected: "",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
index, ok := fileNamedByDiffStatEntry(scenario.entry, paths)
if scenario.expected == "" {
assert.False(t, ok)
return
}
assert.True(t, ok)
assert.Equal(t, scenario.expected, paths[index])
})
}
}
// TestEveryRecordKindIsWeighedAgainstTheDiffStat fails when the protocol gains a kind
// of record that nobody has placed relative to the diffstat. Where a line stating that
// kind can only come below the diffstat, it ends the search for it and belongs in
// diffLineRecordKinds; where it can come above the diff — a record about the commit,
// say — it says nothing about where the diffstat ends, and belongs in the list here.
//
// The parser takes the kind as a field rather than a table, so the kinds it reads are
// found by asking it about each character in turn.
func TestEveryRecordKindIsWeighedAgainstTheDiffStat(t *testing.T) {
kindsAboveTheDiff := []string{}
printable := lo.RangeFrom(byte(' '), 0x7f-' ')
kindsTheParserReads := lo.FilterMap(printable, func(char byte, _ int) (string, bool) {
kind := string([]byte{char})
_, ok := diffLineTypeFromMetadata(kind)
return kind, ok
})
assert.ElementsMatch(t,
append(slices.Clone(diffLineRecordKinds), kindsAboveTheDiff...),
kindsTheParserReads,
"a kind of record has been added to the protocol without being weighed "+
"against the diffstat; see this test's comment for where it belongs")
}
+1 -22
View File
@@ -2,12 +2,9 @@ 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 wraps our hosting_service package, and answers what we know about the
// repo's pull requests on it
// this helper just wraps our hosting_service package
type HostHelper struct {
c *HelperCommon
@@ -21,24 +18,6 @@ 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 {
@@ -667,11 +667,15 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
}
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
return self.c.Helpers().Host.NoPullRequestDisabledReason(self.c.Model().CheckedOutBranch)
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
}
func (self *LocalCommitsController) openPRInBrowser() error {
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.c.Model().CheckedOutBranch)
pr, ok := self.c.Model().PullRequestsMap[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
+67 -258
View File
@@ -1,13 +1,6 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
@@ -22,13 +15,10 @@ type MainViewController struct {
context *context.MainContext
otherContext *context.MainContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
lineFlashGeneration uint64
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
const editedLineFlashDuration = 200 * time.Millisecond
var _ types.IController = &MainViewController{}
func NewMainViewController(
@@ -159,23 +149,6 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.JumpToFile),
Handler: self.openJumpToFileMenu,
Description: self.c.Tr.JumpToFile,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.JumpToFile),
GetDisabledReason: self.diffSelectionDisabledReason,
OpensMenu: true,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser),
Handler: self.openPullRequestAtSelectedLine,
Description: self.c.Tr.OpenPullRequestAtSelectedLine,
DescriptionFunc: self.pullRequestDescription(self.c.Tr.OpenPullRequestAtSelectedLine),
GetDisabledReason: self.openPullRequestDisabledReason,
Tooltip: self.c.Tr.OpenPullRequestAtSelectedLineTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
@@ -268,20 +241,6 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModAlt,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModShift,
Handler: self.editClickedLine,
HandleWhenPopupPanelFocused: true,
},
}
}
@@ -341,22 +300,17 @@ func (self *MainViewController) isDiffView() bool {
return self.diffMainViewType() != types.DiffMainViewTypeNone
}
// sidePanelBeneath returns the side panel this pane is showing the content of, and nil
// when there is none. The IsInStack guard is essential: NextInStack panics for a context
// that isn't in the stack, and GetKeybindings (which leads here) also runs for off-stack
// panes — at startup and while generating the cheatsheets, where the stack is empty.
func (self *MainViewController) sidePanelBeneath() types.Context {
if !self.c.Context().IsInStack(self.context) {
return nil
}
return self.c.Context().NextInStack(self.context)
}
// diffMainViewType reports what the diff in the focused main view belongs to, taken
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
// stack or has no diff panel beneath it.
// stack or has no diff panel beneath it. The IsInStack guard is essential:
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
// leads here) also runs for off-stack panes — at startup and while generating the
// cheatsheets, where the stack is empty.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if diffContext, ok := self.sidePanelBeneath().(types.DiffMainViewContext); ok {
if !self.c.Context().IsInStack(self.context) {
return types.DiffMainViewTypeNone
}
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
@@ -366,7 +320,10 @@ func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
// panel beneath shows no diff.
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
sidePanel := self.sidePanelBeneath()
if !self.c.Context().IsInStack(self.context) {
return nil
}
sidePanel := self.c.Context().NextInStack(self.context)
if sidePanel == nil {
return nil
}
@@ -462,19 +419,6 @@ func (self *MainViewController) diffActionDescription(staging string, patchBuild
}
}
// pullRequestDescription describes a command that acts on the pull request of the
// branch the diff belongs to. Over a diff that belongs to no branch (the working tree's,
// a stash entry's) it describes it as nothing; this keeps the command out of the
// keybindings menu there.
func (self *MainViewController) pullRequestDescription(description string) func() string {
return self.diffSelectionDescription(func() string {
if self.pullRequestBranch() == "" {
return ""
}
return description
})
}
// copySelection copies the selected diff lines to the clipboard — not as the diff
// renderer drew them, but as they read in the diff itself, which is both what you meant
// to copy and the only form a renderer can't have mangled. A selection that is all
@@ -569,90 +513,11 @@ func (self *MainViewController) discardSelectionDisabledReason() *types.Disabled
return nil
}
// openPullRequestDisabledReason disables opening a line in the pull request where the
// pull request has no view of what is on screen. The branch may have no pull request,
// and the pane may be showing a diff that is not the commit's own: a diff against
// another ref, or the custom patch, whose lines sit at the numbers the patch gives them
// rather than the commit's.
func (self *MainViewController) openPullRequestDisabledReason() *types.DisabledReason {
if reason := self.diffSelectionDisabledReason(); reason != nil {
return reason
}
if self.c.Modes().Diffing.Active() {
return &types.DisabledReason{Text: self.c.Tr.NotAvailableInDiffingMode}
}
if self.c.Helpers().DiffLine.ShowsCustomPatch(self.context.GetView()) {
return &types.DisabledReason{Text: self.c.Tr.NotAvailableForCustomPatch}
}
if reason := self.c.Helpers().Host.NoPullRequestDisabledReason(self.pullRequestBranch()); reason != nil {
return reason
}
return self.commitsOutsidePullRequestDisabledReason()
}
// commitsOutsidePullRequestDisabledReason disables opening a line of a diff whose
// commits the pull request doesn't hold: it holds the commits of its branch that are on
// the remote, so an unpushed commit is none of its own, and neither is one that is in a
// main branch already and so from before the branch. Asked for such a commit, its pages
// say they can't find it.
func (self *MainViewController) commitsOutsidePullRequestDisabledReason() *types.DisabledReason {
commits, _ := self.pullRequestCommits()
if lo.EveryBy(commits, func(commit *models.Commit) bool {
return commit.Status == models.StatusPushed
}) {
return nil
}
if len(commits) == 1 {
return &types.DisabledReason{Text: self.c.Tr.CommitNotInPullRequest}
}
return &types.DisabledReason{Text: self.c.Tr.CommitsNotInPullRequest}
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
self.selectClickedDiffLine(opts.Y)
return nil
}
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
var flashGeneration uint64
err := self.editDiffLine(opts.Y, func() {
self.lineFlashGeneration++
flashGeneration = self.lineFlashGeneration
self.context.GetView().SetLineFlash(self.lineToFlash(opts.Y))
self.c.GocuiGui().ForceFlushViewsContentOnly(self.c.GocuiGui().Views())
})
if flashGeneration != 0 {
time.AfterFunc(editedLineFlashDuration, func() {
self.c.OnUIThreadContentOnlyBackground(func() error {
if self.lineFlashGeneration == flashGeneration {
self.context.GetView().ClearLineFlash()
}
return nil
})
})
}
return err
}
// lineToFlash returns the view line to flash for an edit of the line clicked at the
// given one. The editor is sent to where that line begins, so a long line wrapped over
// several view lines is flashed at the first of them. If the editor wraps the line too,
// its cursor ends up on the flashed line. When the line begins above the top of the
// viewport, the clicked line is flashed, as the only part of the line on screen.
func (self *MainViewController) lineToFlash(clickedViewLine int) int {
view := self.context.GetView()
bufferLine, ok := view.BufferLineForViewLine(clickedViewLine)
if !ok {
return clickedViewLine
}
firstViewLine, ok := view.ViewLineForBufferLine(bufferLine)
if !ok || firstViewLine < view.OriginY() {
return clickedViewLine
}
return firstViewLine
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
@@ -847,12 +712,50 @@ func (self *MainViewController) prevFile() error {
return nil
}
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
// screen if it isn't already.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop)
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.collapseNonStickyRange()
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
self.selectHunkAround(target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
}
func (self *MainViewController) openJumpToFileMenu() error {
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(self.context, self.c.Tr.JumpToFile)
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *MainViewController) scrollTargetToTop(target int) {
view := self.context.GetView()
originY, height := self.context.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
@@ -884,7 +787,17 @@ func (self *MainViewController) collapseForLineMove() {
self.context.GetView().CancelRangeSelect()
return
}
self.c.Helpers().DiffLine.CollapseNonStickyRange(self.context)
self.collapseNonStickyRange()
}
// collapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *MainViewController) collapseNonStickyRange() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
}
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
@@ -1053,17 +966,11 @@ func (self *MainViewController) editLine() error {
if !view.Highlight {
return nil
}
return self.editDiffLine(view.SelectedLineIdx(), nil)
}
func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) error {
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(self.context.GetView(), viewLine)
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
if beforeEdit != nil {
beforeEdit()
}
// A file-header row points at the file as a whole rather than at a line in it, so
// it opens the file without jumping anywhere — as pressing edit on a file in a side
@@ -1078,104 +985,6 @@ func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) er
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
// openPullRequestAtSelectedLine opens the pull request of the branch whose commit the
// main view is showing the diff of, at the line the selection is on, so that the line
// can be commented on there.
func (self *MainViewController) openPullRequestAtSelectedLine() error {
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.pullRequestBranch())
if !ok {
// Guarded against by the disabled reason, but a refresh in the background may
// have taken the pull request away since it was asked.
return errors.New(self.c.Tr.NoPullRequestForBranch)
}
commits, baseHash := self.pullRequestCommits()
if len(commits) == 0 {
return nil
}
view := self.context.GetView()
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), info.Path)
if relativePath == "" {
return nil
}
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
url := githubPullRequestLineURL(pr.Url, githubCommitRange(commits, baseHash), relativePath, info)
return self.c.OS().OpenLink(url)
}
// pullRequestBranch returns the branch whose pull request would show the diff in this
// pane, as the panel beneath names it, and "" where no pull request shows it.
func (self *MainViewController) pullRequestBranch() string {
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
if !ok {
return ""
}
return prContext.BranchForPullRequest()
}
// pullRequestCommits returns the commits whose diff the pane is showing, and the commit
// that diff starts after, as the panel beneath names them. The diff's line numbers are
// the ones the pull request's page for those commits shows.
func (self *MainViewController) pullRequestCommits() ([]*models.Commit, string) {
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
if !ok {
return nil, ""
}
return prContext.CommitsForPullRequest()
}
// githubPullRequestLineURL builds the URL of a line of a file, in the diff a pull request
// shows for the given commits. The file is named by the SHA-256 of its path as git spells
// it, and the line by which side of the diff it is on.
//
// GitHub documents none of this; the form was read off the URLs its own pages carry (see
// https://github.com/orgs/community/discussions/55764).
func githubPullRequestLineURL(
prURL string, commitRange string, relativePath string, info types.DiffLineInfo,
) string {
pathHash := sha256.Sum256([]byte(relativePath))
anchor := "diff-" + hex.EncodeToString(pathHash[:]) + githubDiffLineSuffix(info)
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitRange, anchor)
}
// githubCommitRange names the commits a pull request is to show the diff of: a single
// commit by its hash, and a range of them as the commit the diff starts after, then the
// commit it ends at. A range that starts where the pull request itself does names BASE
// as the commit it starts after, the keyword its pages use for the commit the pull
// request was opened against; naming that commit by its hash gets a page that says it
// can't find those commits.
func githubCommitRange(commits []*models.Commit, baseHash string) string {
newest := commits[0].Hash()
if len(commits) == 1 {
return newest
}
if baseHash == "" {
baseHash = "BASE"
}
return baseHash + ".." + newest
}
// githubDiffLineSuffix names a line within a file's diff: R for the new version of the
// file, L for the old one, which is where a deleted line is found. Some rows are no line
// of the file at all (the header naming it, or a marker like "\ No newline at end of
// file"); those name none, and the anchor points at the file itself.
func githubDiffLineSuffix(info types.DiffLineInfo) string {
switch info.Type {
case types.DiffLineDeleted:
return fmt.Sprintf("L%d", info.OldLine)
case types.DiffLineAdded, types.DiffLineContext, types.DiffLineHunkHeader:
return fmt.Sprintf("R%d", info.NewLine)
default:
return ""
}
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
@@ -1,115 +0,0 @@
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))
})
}
}
@@ -2,6 +2,7 @@ package controllers
import (
"fmt"
"path/filepath"
"strings"
"github.com/jesseduffield/generics/set"
@@ -261,11 +262,11 @@ func (self *WorkingTreeDiffActions) applyDiffLineSelection(
// fileForDiffLinePath maps the absolute path a diff line carries to the working tree
// file it belongs to, or nil for a path that is no file of this repo's working tree.
func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.File {
relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
if relativePath == "" {
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
if err != nil {
return nil
}
return self.context().FileTreeViewModel.GetFile(relativePath)
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
}
// applyDiffLines applies the given change lines of one file — a line, a hunk, a range —
+11 -28
View File
@@ -82,9 +82,6 @@ type Gui struct {
statusManager *status.StatusManager
waitForIntro sync.WaitGroup
viewBufferManagerMap map[string]*tasks.ViewBufferManager
// holds a mapping of the main section's view names to the writers that link the
// files named in the diffstat of what is rendered into them
diffStatLinkWriterMap map[string]*helpers.DiffStatLinkWriter
// holds a mapping of view names to ptmx's. This is for rendering command outputs
// from within a pty. The point of keeping track of them is so that if we re-size
// the window, we can tell the pty it needs to resize accordingly.
@@ -418,17 +415,6 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
return gui.helpers.Files.EditFiles([]string{filepath})
}
if entry, ok := strings.CutPrefix(url, helpers.DiffStatLinkScheme); ok {
view, err := gui.g.View(viewname)
if err != nil {
return nil
}
if pane := gui.mainContextForView(view); pane != nil {
gui.helpers.DiffLine.JumpToFileNamedInDiffStat(pane, entry)
}
return nil
}
if err := gui.os.OpenLink(url); err != nil {
return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
}
@@ -789,18 +775,17 @@ func NewGui(
test integrationTypes.IntegrationTest,
) (*Gui, error) {
gui := &Gui{
Common: cmn,
gitVersion: gitVersion,
Config: configurer,
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
diffStatLinkWriterMap: map[string]*helpers.DiffStatLinkWriter{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
Common: cmn,
gitVersion: gitVersion,
Config: configurer,
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
// initializing this to true for the time being; it will be reset to the
// real value after loading the user config:
@@ -953,8 +938,6 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
g.ErrorHandler = gui.PopupHandler.ErrorHandler
terminalName, terminalVersion := g.Terminal()
gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion)
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
+11 -19
View File
@@ -51,40 +51,34 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) {
}
func (self *GuiDriver) Click(x, y int) {
self.ClickWithModifier(x, y, gocui.ModNone)
}
// ClickWithModifier clicks with a keyboard modifier held down for the whole
// gesture, as a terminal reports it.
func (self *GuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary, modifier)
self.replayMouseEvent(x, y, tcell.ButtonNone, modifier)
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) ClickAndHold(x, y int) {
self.CheckAllToastsAcknowledged()
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
// MouseMove reports the mouse at a new position with the left button still
// held down, i.e. a drag movement. (No test needs pointer motion without a
// button held, so that variant doesn't exist.)
func (self *GuiDriver) MouseMove(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
func (self *GuiDriver) ScrollWheelDown(x, y int) {
self.replayMouseEvent(x, y, tcell.WheelDown, gocui.ModNone)
self.replayMouseEvent(x, y, tcell.WheelDown)
}
func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone, gocui.ModNone)
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) {
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone, gocui.ModNone)
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) WaitUntilIdle() {
@@ -95,16 +89,14 @@ func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(f)
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier) {
self.replayMouseEventWithoutWaiting(x, y, buttons, modifier)
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
self.replayMouseEventWithoutWaiting(x, y, buttons)
self.waitTillIdle()
}
func (self *GuiDriver) replayMouseEventWithoutWaiting(
x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier,
) {
func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) {
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, buttons, tcell.ModMask(modifier)),
tcell.NewEventMouse(x, y, buttons, 0),
0,
))
}
-6
View File
@@ -74,12 +74,6 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
// or a log, and reads as badly cut off at the edge of the pane as it would
// anywhere else.
view.Wrap = !mainContext.ContentIsDiff() || gui.c.UserConfig().Gui.WrapLinesInDiffView
// The files named in the diffstat are linked to where their diff begins, over a
// render that has both: the panel's own diff, and rows that can be placed in the
// files they show. The writer is told here, on the UI thread, since it is asked
// on the one reading the command's output.
gui.diffStatLinkWriter(view).BeginRender(
mainContext.ContentIsDiff() && gui.helpers.DiffLine.DiffRowsCanBePlaced())
}
if err := gui.runTaskForView(view, opts.Task); err != nil {
-4
View File
@@ -59,7 +59,6 @@ func GetCommitListDisplayStrings(
startIdx int,
endIdx int,
showGraph bool,
graphSymbolSet graph.SymbolSet,
bisectInfo *git_commands.BisectInfo,
) [][]string {
mutex.Lock()
@@ -108,7 +107,6 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
allGraphLines = append(allGraphLines, graphLines...)
}
@@ -126,7 +124,6 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
allGraphLines = append(allGraphLines, graphLines...)
}
@@ -148,7 +145,6 @@ func GetCommitListDisplayStrings(
graphPipeSets,
graphCommits,
selectedCommitHashPtr,
graphSymbolSet,
)
getGraphLine = func(idx int) string {
if idx >= graphOffset {
+1 -2
View File
@@ -611,7 +611,6 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
s.startIdx,
s.endIdx,
s.showGraph,
graph.BoxDrawingSymbols,
s.bisectInfo,
)
@@ -636,7 +635,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
}
renderGraph := func() string {
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n")
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
}
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
@@ -1,132 +0,0 @@
package graph
import (
"slices"
"strconv"
"strings"
)
// The branch drawing symbols are characters in the Unicode Private Use Area
// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in
// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing
// characters, they can show how the lines in a cell connect. For example, there
// is a symbol for a cell in which a line from above bends to the left and a
// line from the left bends down.
const branchDrawingHorizontal = "\uf5d0"
// The lines in a cell that isn't a commit
type lineCourses struct {
up, down verticalLine
horizontal bool
}
var branchDrawingConnections = map[lineCourses]string{
{}: " ",
{horizontal: true}: branchDrawingHorizontal,
// A horizontal line passing behind a vertical one only shows in the
// neighbouring cells
{up: straightLine, down: straightLine}: "\uf5d1", // │
{up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │
{up: lineToLeft}: "\uf5d9", // ╯
{up: lineToRight}: "\uf5d8", // ╰
{down: lineToLeft}: "\uf5d7", // ╮
{down: lineToRight}: "\uf5d6", // ╭
{up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─
{up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─
{down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─
{down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─
{up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮
{up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭
{up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─
{up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─
{up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─
{up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─
}
// The edges of a commit's cell that its lines touch
type commitEdges struct {
up, down, left, right bool
}
// Commits are drawn as hollow circles, merge commits as filled ones. Both
// connect to the lines at the given edges.
var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{
{}: {"\uf5ef", "\uf5ee"},
{right: true}: {"\uf5f1", "\uf5f0"},
{left: true}: {"\uf5f3", "\uf5f2"},
{left: true, right: true}: {"\uf5f5", "\uf5f4"},
{down: true}: {"\uf5f7", "\uf5f6"},
{up: true}: {"\uf5f9", "\uf5f8"},
{up: true, down: true}: {"\uf5fb", "\uf5fa"},
{down: true, right: true}: {"\uf5fd", "\uf5fc"},
{down: true, left: true}: {"\uf5ff", "\uf5fe"},
{up: true, right: true}: {"\uf601", "\uf600"},
{up: true, left: true}: {"\uf603", "\uf602"},
{up: true, down: true, right: true}: {"\uf605", "\uf604"},
{up: true, down: true, left: true}: {"\uf607", "\uf606"},
{down: true, left: true, right: true}: {"\uf609", "\uf608"},
{up: true, left: true, right: true}: {"\uf60b", "\uf60a"},
{up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"},
}
func (cell *Cell) branchDrawingChars() (string, string) {
second := " "
if cell.right {
second = branchDrawingHorizontal
}
switch cell.cellType {
case COMMIT, MERGE:
symbols := branchDrawingCommitSymbols[commitEdges{
up: cell.up != noLine,
down: cell.down != noLine,
left: cell.left,
right: cell.right,
}]
if cell.cellType == MERGE {
return symbols.merge, second
}
return symbols.commit, second
default:
if cell.horizontalOnTop {
return branchDrawingHorizontal, second
}
if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok {
return first, second
}
// There is no symbol for lines that the layout of the graph never
// produces, such as a lone half of a line
first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
return first, second
}
}
// The terminals that draw the branch drawing symbols themselves, with the
// first version that draws all of them
var terminalsWithBranchDrawingSymbols = map[string][]int{
"kitty": {0, 36, 2},
"ghostty": {1, 0, 0},
}
// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw
// the branch drawing symbols itself, given the name and version it reports
func TerminalDrawsBranchDrawingSymbols(name, version string) bool {
minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)]
return ok && slices.Compare(versionNumbers(version), minVersion) >= 0
}
// The numbers at the start of the dot-separated parts of a version, e.g.
// [1 3 0] for "1.3.0-dev+abc"
func versionNumbers(version string) []int {
var numbers []int
for part := range strings.SplitSeq(version, ".") {
digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))]
number, err := strconv.Atoi(digits)
if err != nil {
break
}
numbers = append(numbers, number)
}
return numbers
}
@@ -1,33 +0,0 @@
package graph
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) {
tests := []struct {
name string
version string
expected bool
}{
{name: "kitty", version: "0.36.1", expected: false},
{name: "kitty", version: "0.36.2", expected: true},
{name: "kitty", version: "0.44.0", expected: true},
{name: "ghostty", version: "0.9.0", expected: false},
{name: "ghostty", version: "1.0.0", expected: true},
{name: "ghostty", version: "1.3.0-main+0123abcd", expected: true},
{name: "WezTerm", version: "20250601-102030-89abcdef", expected: false},
{name: "tmux", version: "3.5a", expected: false},
{name: "iTerm2", version: "3.6.4", expected: false},
{name: "kitty", version: "", expected: false},
{name: "", version: "", expected: false},
}
for _, test := range tests {
t.Run(test.name+" "+test.version, func(t *testing.T) {
assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version))
})
}
}
+25 -66
View File
@@ -13,14 +13,6 @@ const (
CommitSymbol = '○'
)
// The characters that the commit graph is drawn with
type SymbolSet uint8
const (
BoxDrawingSymbols SymbolSet = iota
BranchDrawingSymbols
)
type cellType int
const (
@@ -29,40 +21,25 @@ const (
MERGE
)
// How a line that touches the top or bottom edge of a cell runs within it
type verticalLine uint8
const (
noLine verticalLine = iota
// On to the opposite edge, or into the commit symbol
straightLine
// Bends towards the left edge
lineToLeft
// Bends towards the right edge
lineToRight
)
type Cell struct {
// The lines that touch the top and bottom edges
up, down verticalLine
// Whether lines touch the left and right edges
left, right bool
// Whether a line passes through from the left edge to the right edge
horizontal bool
// Whether that line is drawn over the vertical line that it crosses
horizontalOnTop bool
cellType cellType
rightStyle *style.TextStyle
style *style.TextStyle
up, down, left, right bool
cellType cellType
rightStyle *style.TextStyle
style *style.TextStyle
}
func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
var first, second string
switch symbolSet {
case BoxDrawingSymbols:
first, second = cell.boxDrawingChars()
case BranchDrawingSymbols:
first, second = cell.branchDrawingChars()
func (cell *Cell) render(writer io.StringWriter) {
up, down, left, right := cell.up, cell.down, cell.left, cell.right
first, second := getBoxDrawingChars(up, down, left, right)
var adjustedFirst string
switch cell.cellType {
case CONNECTION:
adjustedFirst = first
case COMMIT:
adjustedFirst = string(CommitSymbol)
case MERGE:
adjustedFirst = string(MergeSymbol)
}
var rightStyle *style.TextStyle
@@ -82,22 +59,10 @@ func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
styledSecondChar = cachedSprint(*rightStyle, second)
}
_, _ = writer.WriteString(cachedSprint(*cell.style, first))
_, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst))
_, _ = writer.WriteString(styledSecondChar)
}
func (cell *Cell) boxDrawingChars() (string, string) {
first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
switch cell.cellType {
case COMMIT:
return string(CommitSymbol), second
case MERGE:
return string(MergeSymbol), second
default:
return first, second
}
}
type rgbCacheKey struct {
*color.RGBStyle
str string
@@ -133,35 +98,34 @@ func cachedSprint(style style.TextStyle, str string) string {
}
func (cell *Cell) reset() {
cell.up = noLine
cell.down = noLine
cell.up = false
cell.down = false
cell.left = false
cell.right = false
cell.horizontal = false
cell.horizontalOnTop = false
}
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
cell.up = line
func (cell *Cell) setUp(style *style.TextStyle) *Cell {
cell.up = true
cell.style = style
return cell
}
func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell {
cell.down = line
func (cell *Cell) setDown(style *style.TextStyle) *Cell {
cell.down = true
cell.style = style
return cell
}
func (cell *Cell) setLeft(style *style.TextStyle) *Cell {
cell.left = true
if cell.up == noLine && cell.down == noLine {
if !cell.up && !cell.down {
// vertical trumps left
cell.style = style
}
return cell
}
//nolint:unparam
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
cell.right = true
if cell.rightStyle == nil || override {
@@ -170,11 +134,6 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
return cell
}
func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell {
cell.horizontal = true
return cell.setLeft(style).setRight(style, overrideRightStyle)
}
func (cell *Cell) setStyle(style *style.TextStyle) *Cell {
cell.style = style
return cell
+13 -60
View File
@@ -45,13 +45,13 @@ func (self Pipe) right() int16 {
return max(self.fromPos, self.toPos)
}
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string {
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string {
pipeSets := GetPipeSets(commits, getStyle)
if len(pipeSets) == 0 {
return nil
}
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet)
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr)
return lines
}
@@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl
})
}
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string {
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string {
maxProcs := runtime.GOMAXPROCS(0)
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
@@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
if k > 0 {
prevCommit = commits[k-1]
}
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet)
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit)
innerLines = append(innerLines, line)
}
chunks[i] = innerLines
@@ -276,7 +276,6 @@ func renderPipeSet(
pipes []Pipe,
selectedCommitHashPtr *string,
prevCommit *models.Commit,
symbolSet SymbolSet,
) string {
maxPos := int16(0)
commitPos := int16(0)
@@ -305,17 +304,17 @@ func renderPipeSet(
if left != right {
for i := left + 1; i < right; i++ {
cells[i].setHorizontal(style, overrideRightStyle)
cells[i].setLeft(style).setRight(style, overrideRightStyle)
}
cells[left].setRight(style, overrideRightStyle)
cells[right].setLeft(style)
}
if pipe.kind == STARTS || pipe.kind == CONTINUES {
cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos))
cells[pipe.toPos].setDown(style)
}
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos))
cells[pipe.fromPos].setUp(style)
}
}
@@ -344,31 +343,14 @@ func renderPipeSet(
}
for _, pipe := range nonSelectedPipes {
if pipe.kind == STARTS {
continue
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
renderPipe(&pipe, pipe.style, false)
}
if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos {
// Record this line without drawing it, so that the commit symbol
// keeps the style of the commit itself. The branch drawing commit
// symbols connect to it. The pipe from above the first commit
// doesn't come from a commit, so it has no line.
if !equalHashes(pipe.fromHash, &StartCommitHash) {
cells[commitPos].up = straightLine
}
continue
}
renderPipe(&pipe, pipe.style, false)
}
// Box drawing characters can't show every combination of lines in a cell,
// so the cells that the selected commit's lines run through show only
// those lines. The branch drawing symbols can, so they keep the other
// lines too.
if symbolSet == BoxDrawingSymbols {
for _, pipe := range selectedPipes {
for i := pipe.left(); i <= pipe.right(); i++ {
cells[i].reset()
}
for _, pipe := range selectedPipes {
for i := pipe.left(); i <= pipe.right(); i++ {
cells[i].reset()
}
}
for _, pipe := range selectedPipes {
@@ -378,22 +360,6 @@ func renderPipeSet(
}
}
// Where a line of the selected commit crosses the vertical line of another
// commit, draw it over that line, so that it reads as one line. The
// vertical line visibly continues above and below. A vertical line in a
// cell that doesn't have the highlight style belongs to another commit.
if symbolSet == BranchDrawingSymbols {
for _, pipe := range selectedPipes {
for i := pipe.left() + 1; i < pipe.right(); i++ {
cell := cells[i]
if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle {
cell.horizontalOnTop = true
cell.style = &highlightStyle
}
}
}
}
cType := COMMIT
if isMerge {
cType = MERGE
@@ -405,24 +371,11 @@ func renderPipeSet(
writer := &strings.Builder{}
writer.Grow(len(cells) * 2)
for _, cell := range cells {
cell.render(writer, symbolSet)
cell.render(writer)
}
return writer.String()
}
// The course of a line through the top or bottom edge of the cell at pos,
// given the column of the pipe's other end
func lineTowards(pos, otherPos int16) verticalLine {
switch {
case otherPos < pos:
return lineToLeft
case otherPos > pos:
return lineToRight
default:
return straightLine
}
}
func equalHashes(a, b *string) bool {
// if our selectedCommitHashPtr is nil, there is no selected commit
if a == nil || b == nil {
+5 -324
View File
@@ -224,7 +224,7 @@ func TestRenderCommitGraph(t *testing.T) {
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
commits := lo.Map(test.commitOpts,
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols)
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle)
trimmedExpectedOutput := ""
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
@@ -247,325 +247,6 @@ func TestRenderCommitGraph(t *testing.T) {
}
}
// Box drawing look-alikes for the branch drawing symbols, so that the expected
// output in tests is readable. The box drawing characters of the graph never
// use ┤ and ┼; here they stand for the symbols that have two bends. Commit
// symbols are shown as ○ and ◎, whichever lines they connect to.
var branchDrawingLookAlikes = func() map[rune]rune {
lookAlikes := map[rune]rune{
'\uf5d0': '─',
'\uf5d1': '│',
'\uf5d6': '╭',
'\uf5d7': '╮',
'\uf5d8': '╰',
'\uf5d9': '╯',
'\uf5e0': '┬', // ╮ on ─
'\uf5e3': '┴', // ╯ on ─
'\uf5df': '┤', // ╯ and ╮
'\uf5e8': '┼', // ╯ and ╮ on ─
}
for _, symbols := range branchDrawingCommitSymbols {
lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol
lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol
}
return lookAlikes
}()
func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) {
tests := []struct {
name string
commitOpts []models.NewCommitOpts
selectedHash string
expectedOutput string
}{
{
name: "branch forked off a merge commit",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─│─╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with the merge commit selected",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
selectedHash: "5",
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─│─╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with the merge commit above it selected",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"5"}},
{Hash: "2", Parents: []string{"5", "4"}},
{Hash: "4", Parents: []string{"5"}},
{Hash: "5", Parents: []string{"6", "7"}},
{Hash: "7", Parents: []string{"6"}},
{Hash: "6", Parents: []string{"8"}},
},
selectedHash: "2",
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎───╮
4 │ │ ○
5 ◎─┼─╯
7 │ ○
6 ○─╯`,
},
{
name: "branch forked off a merge commit, with no line passing through",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2", "3"}},
{Hash: "3", Parents: []string{"2"}},
{Hash: "2", Parents: []string{"4", "5"}},
{Hash: "4", Parents: []string{"6", "7"}},
{Hash: "6", Parents: []string{"8"}},
},
expectedOutput: `
1 ◎─╮
3 │ ○
2 ◎─┤
4 ◎─│─╮
6 ○ │ │`,
},
{
name: "several lines ending in a commit",
commitOpts: []models.NewCommitOpts{
{Hash: "1", Parents: []string{"2"}},
{Hash: "2", Parents: []string{"3", "4"}},
{Hash: "3", Parents: []string{"5", "4"}},
{Hash: "5", Parents: []string{"7", "8"}},
{Hash: "7", Parents: []string{"4", "A"}},
{Hash: "4", Parents: []string{"B"}},
{Hash: "B", Parents: []string{"C"}},
},
expectedOutput: `
1 ○
2 ◎─╮
3 ◎─│─╮
5 ◎─│─│─╮
7 ◎─│─│─│─╮
4 ○─┴─╯ │ │
B ○ ╭───╯ │`,
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
hashPool := &utils.StringPool{}
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
commits := lo.Map(test.commitOpts,
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols)
trimmedExpectedOutput := ""
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
trimmedExpectedOutput += strings.TrimSpace(line) + "\n"
}
output := ""
for i, line := range lines {
lookAlikes := strings.Map(func(r rune) rune {
return lo.ValueOr(branchDrawingLookAlikes, r, r)
}, utils.Decolorise(line))
output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n"
}
assert.Equal(t, trimmedExpectedOutput, output)
})
}
}
func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) {
cyan := style.FgCyan
red := style.FgRed
green := style.FgGreen
yellow := style.FgYellow
magenta := style.FgMagenta
nothing := style.Nothing
hashPool := &utils.StringPool{}
pool := func(s string) *string { return hashPool.Add(s) }
tests := []struct {
name string
pipes []Pipe
prevCommit *models.Commit
expectedStr string
expectedStyles []style.TextStyle
}{
{
name: "commit with lines above and below",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
expectedStr: "\uf5fb", // ○ with lines up and down
expectedStyles: []style.TextStyle{green},
},
{
name: "first commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
},
expectedStr: "\uf5f7", // ○ with a line down
expectedStyles: []style.TextStyle{green},
},
{
name: "selected commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
expectedStr: "\uf5fb", // ○ with lines up and down
expectedStyles: []style.TextStyle{highlightStyle},
},
{
name: "commit whose previous commit is selected and is a merge commit",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}),
expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │
expectedStyles: []style.TextStyle{
highlightStyle, nothing, highlightStyle,
},
},
{
name: "merge commit that a branch forks off",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
green, green, magenta, cyan, cyan,
},
},
{
name: "merge commit that a branch forks off, with the branch's first commit selected",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, highlightStyle, cyan, cyan,
},
},
{
name: "line of the selected commit crossing another line",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red},
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow},
{fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta},
{fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green},
{fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green,
},
},
{
name: "line of the selected commit passing through a cell in which other lines bend",
pipes: []Pipe{
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
{fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
},
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
expectedStyles: []style.TextStyle{
highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle,
},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
defer color.ForceSetColorLevel(oldColorLevel)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols)
if len([]rune(test.expectedStr)) != len(test.expectedStyles) {
t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles))
}
expectedStr := ""
for i, char := range []rune(test.expectedStr) {
expectedStr += test.expectedStyles[i].Sprint(string(char))
}
expectedStr += " "
assert.Equal(t, expectedStr, actualStr)
})
}
}
func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) {
hashPool := &utils.StringPool{}
commits := generateCommits(hashPool, 1000)
getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault }
isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' }
for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} {
lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols)
for i, line := range lines {
line = utils.Decolorise(line)
assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar),
"row %d falls back to box drawing characters: %q", i, line)
}
}
}
func TestRenderPipeSet(t *testing.T) {
cyan := style.FgCyan
red := style.FgRed
@@ -781,7 +462,7 @@ func TestRenderPipeSet(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols)
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit)
t.Log("actual cells:")
t.Log(actualStr)
expectedStr := ""
@@ -859,8 +540,8 @@ func TestGetNextPipes(t *testing.T) {
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
// rendering cells so that it's easier to see what went wrong
actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols)
expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols)
actualStr := renderPipeSet(pipes, pool("selected"), nil)
expectedStr := renderPipeSet(test.expected, pool("selected"), nil)
t.Log("expected cells:")
t.Log(expectedStr)
t.Log("actual cells:")
@@ -881,7 +562,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
}
b.ResetTimer()
for b.Loop() {
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols)
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle)
}
}
+1 -24
View File
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/sirupsen/logrus"
)
@@ -157,34 +156,12 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
return nil
}
// contentWriter returns what a render of the given view writes its content to: the
// view itself, or, for a pane of the main section, the writer that links the files
// named in the diffstat on its way there (see DiffStatLinkWriter).
func (gui *Gui) contentWriter(view *gocui.View) io.Writer {
if gui.mainContextForView(view) == nil {
return view
}
return gui.diffStatLinkWriter(view)
}
// diffStatLinkWriter returns the writer that links the diffstat of the given pane,
// making it if the pane hasn't rendered yet. It lasts as long as the view does, and
// each render tells it what to make of that render (see DiffStatLinkWriter.BeginRender).
func (gui *Gui) diffStatLinkWriter(view *gocui.View) *helpers.DiffStatLinkWriter {
writer, ok := gui.diffStatLinkWriterMap[view.Name()]
if !ok {
writer = helpers.NewDiffStatLinkWriter(view)
gui.diffStatLinkWriterMap[view.Name()] = writer
}
return writer
}
func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
manager, ok := gui.viewBufferManagerMap[view.Name()]
if !ok {
manager = tasks.NewViewBufferManager(
gui.Log,
gui.contentWriter(view),
view,
func() {
// Called before showing the "loading..." indicator: clear the
// displayed buffer so only "loading..." is shown. The actual content
-24
View File
@@ -1,7 +1,6 @@
package types
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -214,29 +213,6 @@ const (
DiffMainViewTypePatchBuilding
)
// PullRequestDiffContext is implemented by the side panel contexts that show, in
// their focused main view, the diff of a commit of a branch: the commits panel and
// the sub-commits panel, and the commit files panel entered from either of them. A
// pull request for that branch has a view of that commit, so a line of the diff can
// be pointed at in it. A panel showing a diff that no pull request has a view of (the
// working tree's, a stash entry's, a reflog entry's) doesn't implement this.
type PullRequestDiffContext interface {
Context
// BranchForPullRequest returns the local branch whose pull request would show
// the diff in the main view, and "" where no branch does: the panel may have
// nothing selected, HEAD may be detached, or what was drilled into may be a tag
// or a remote branch rather than a local one.
BranchForPullRequest() string
// CommitsForPullRequest returns the commits whose combined diff the main view is
// showing, newest first as the panel lists them, together with the hash of the
// commit that diff starts after: the parent of the oldest of them, where the
// pull request has that parent as one of its own commits, and "" where the diff
// starts where the pull request itself does.
CommitsForPullRequest() ([]*models.Commit, string)
}
// DiffPaneContext is one of the two panes the main section can show, as the thing
// that holds a diff with a selection in it. The panels that act on such a selection
// are handed the pane it was made in, and speak to it through this.
-22
View File
@@ -293,8 +293,6 @@ type TranslationSet struct {
UnsupportedGitService string
CopyPullRequestURL string
OpenPullRequestInBrowser string
OpenPullRequestAtSelectedLine string
OpenPullRequestAtSelectedLineTooltip string
NoPullRequestForBranch string
NoBranchOnRemote string
Fetch string
@@ -312,10 +310,6 @@ type TranslationSet struct {
ToggleSelectHunk string
SelectHunk string
NothingToSelectInDiff string
NotAvailableInDiffingMode string
NotAvailableForCustomPatch string
CommitNotInPullRequest string
CommitsNotInPullRequest string
SelectLineByLine string
ToggleSelectHunkTooltip string
ToggleSelectionForPatch string
@@ -416,11 +410,6 @@ type TranslationSet struct {
NextHunk string
PrevFileInDiff string
NextFileInDiff string
JumpToFile string
JumpToFileInDiff string
JumpToFileInDiffTooltip string
OnlyOneFileInDiff string
NoFileInDiffNamed string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -1478,8 +1467,6 @@ func EnglishTranslationSet() *TranslationSet {
CreatePullRequest: `Create pull request`,
CopyPullRequestURL: `Copy pull request URL to clipboard`,
OpenPullRequestInBrowser: `Open pull request in browser`,
OpenPullRequestAtSelectedLine: `Open pull request at selected line`,
OpenPullRequestAtSelectedLineTooltip: "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.",
NoPullRequestForBranch: `No pull request found for this branch`,
NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`,
Fetch: `Fetch`,
@@ -1498,10 +1485,6 @@ func EnglishTranslationSet() *TranslationSet {
DismissRangeSelect: "Dismiss range select",
ToggleSelectHunk: "Toggle hunk selection",
NothingToSelectInDiff: "There is nothing to select here",
NotAvailableInDiffingMode: "Not available in diffing mode",
NotAvailableForCustomPatch: "Not available for the custom patch",
CommitNotInPullRequest: "This commit is not part of the pull request",
CommitsNotInPullRequest: "Not all of these commits are part of the pull request",
SelectHunk: "Select hunks",
SelectLineByLine: "Select line-by-line",
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
@@ -1607,11 +1590,6 @@ func EnglishTranslationSet() *TranslationSet {
NextHunk: "Go to next hunk",
PrevFileInDiff: "Go to previous file",
NextFileInDiff: "Go to next file",
JumpToFile: "Jump to file",
JumpToFileInDiff: "Jump to file in diff",
JumpToFileInDiffTooltip: "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.",
OnlyOneFileInDiff: "There is only one file in this diff",
NoFileInDiffNamed: "This diff has no file named '{{.path}}'",
PrevConflict: "Previous conflict",
NextConflict: "Next conflict",
SelectPrevHunk: "Previous hunk",
@@ -6,7 +6,6 @@ import (
"time"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
)
@@ -61,12 +60,6 @@ func (self *TestDriver) click(x, y int) {
self.Wait(self.inputDelay)
}
func (self *TestDriver) clickWithModifier(x, y int, modifier gocui.Modifier, what string) {
self.SetCaption(fmt.Sprintf("%s-clicking %d, %d", what, x, y))
self.gui.ClickWithModifier(x, y, modifier)
self.Wait(self.inputDelay)
}
func (self *TestDriver) clickAndHold(x, y int) {
self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y))
self.mouseX, self.mouseY = x, y
+2 -10
View File
@@ -47,10 +47,6 @@ func (self *fakeGuiDriver) Click(x, y int) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ClickAndHold(x, y int) {
self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y})
}
@@ -204,8 +200,6 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
viewDriver.
Click(1, 2).
AltClick(2, 3).
ShiftClick(4, 5).
FocusInAndClick(3, 4).
ClickAndHold(5, 6).
MouseMove(7, 8).
@@ -213,13 +207,11 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
MouseMoveToView(targetViewDriver, 10, 11).
ScrollWheelDown()
assert.Equal(t,
[]coordinate{{12, 23}, {13, 24}, {15, 26}, {14, 25}},
guiDriver.clickedCoordinates)
assert.Equal(t, []coordinate{{12, 23}, {14, 25}}, guiDriver.clickedCoordinates)
assert.Equal(t, []coordinate{{16, 27}}, guiDriver.heldCoordinates)
assert.Equal(t, []coordinate{{18, 29}, {20, 30}, {51, 62}}, guiDriver.movedCoordinates)
assert.Equal(t, []coordinate{{11, 21}}, guiDriver.scrolledCoordinates)
assert.Equal(t, 9, guiDriver.onUIThreadCallCount)
assert.Equal(t, 7, guiDriver.onUIThreadCallCount)
}
func TestFailingFixture(t *testing.T) {
-19
View File
@@ -726,25 +726,6 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver {
return self
}
// AltClick and ShiftClick click with a modifier held down. Both modifiers are
// bound to the same gestures, because no single one of them reaches lazygit in
// every terminal.
func (self *ViewDriver) AltClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModAlt, "Alt")
return self
}
func (self *ViewDriver) ShiftClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModShift, "Shift")
return self
}
func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver {
offsetX, offsetY, _ := self.viewGeometry()
@@ -1,58 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickAFileInADiffStatThatComesFirst = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file by clicking its name in a diffstat the renderer's handshake runs into",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
// A renderer that announces the protocol and then passes the diff on as it came.
// The handshake has no newline after it, so it runs into the first line the
// renderer is given — which for the diff of a range of commits is the first
// entry of the diffstat, there being no commit above it.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `printf '\033]1717;1\007'; cat`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("aaa.txt", "one\n")
shell.Commit("one")
shell.CreateFileAndAdd("zzz.txt", "one\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("two")).
Press(keys.Universal.ToggleRangeSelect).
SelectNextItem().
SelectedLines(
Contains("two"),
Contains("one"),
)
// Below the line lazygit writes to say what the diff is of, the diff opens with
// the diffstat, and the handshake runs into its first entry. That is the one
// clicked here.
t.Views().Main().
TopLines(
Contains("Showing diff for range"),
Equals(""),
Contains("aaa.txt"),
Contains("zzz.txt"),
Contains("2 files changed"),
).
Click(2, 2)
t.Views().Commits().IsFocused()
t.Views().Main().
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt"))
},
})
@@ -1,90 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickAFileInTheDiffStat = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file of a commit's diff by clicking the line that names it in the diffstat",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
// A long file at either end, so that the file jumped to is far below the
// diffstat and has a diff under it to scroll past.
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("one"))
// The click below is at a line of the diffstat, so the diff has to open with
// the lines this expects.
t.Views().Main().
TopLines(
Contains("commit"),
Contains("Author:"),
Contains("Date:"),
Equals(""),
Contains("one"),
Equals("---"),
Contains("aaa.txt"),
Contains("dir/bbb.txt"),
Contains("zzz.txt"),
Contains("3 files changed"),
).
Click(2, 8)
// The panel keeps the focus, and the diff goes to the file clicked.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one"))
t.Views().Main().
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
t.Views().Commits().
IsFocused().
Press(keys.Universal.FocusMainView)
// With the pane focused there is a selection to move, and the click moves it to
// the file, exactly as picking the file from the menu would.
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.GotoTop)
// The first file's diff begins on screen already, so the view stays where it
// is; a jump only scrolls as far as it must once there is a selection to point
// at the file with.
t.Views().Main().
TopVisibleLine(Contains("commit")).
Click(2, 6).
SelectedLines(
Contains("diff --git a/aaa.txt b/aaa.txt"),
).
TopVisibleLine(Contains("commit")).
// The last file's is far below, so that one is scrolled to.
Click(2, 8).
SelectedLines(
Contains("diff --git a/zzz.txt b/zzz.txt"),
).
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
},
})
@@ -1,66 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EditClickedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Alt- or shift-click a line of the main view's diff to open it in the editor, without focusing the view",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused()
// The click points at the line itself, so the main view can stay unfocused
// and unselected. You read a diff where it is and click into it.
t.Views().Main().
AltClick(0, 8).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
}).
SelectionIsHidden()
t.Views().Files().
IsFocused()
// Shift-click is bound to the same thing, since neither modifier reaches
// lazygit in every terminal. A context line names a line of the file like any
// other row.
t.Views().Main().
ShiftClick(0, 9).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:4\n"))
})
// A popup taking the focus swallows clicks on the views behind it. This one
// stays live, so a diff can still be read and clicked into while a popup is
// up.
t.Views().Files().
Press(keys.Universal.Remove)
t.Views().Menu().
IsFocused()
t.Views().Main().
AltClick(0, 6).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:2\n"))
})
t.Views().Menu().
IsFocused().
Press(keys.Universal.Return)
},
})
@@ -1,155 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var JumpToAFileOfTheDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file of a commit's diff by picking it from a menu of the diff's files",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the first file's diff is longer than the part of the
// diff that has been read when the menu asks which files there are.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("ccc.txt", "one\n")
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
// Another long one at the end, so that the file jumped to below has a diff
// under it to scroll past and ends up at the top of the view.
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
shell.UpdateFileAndAdd("ccc.txt", "two\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// 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.
t.Views().Commits().
Focus().
SelectedLine(Contains("two")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.JumpToFile)
t.ExpectToast(Contains("There is only one file in this diff"))
t.Views().Main().
IsFocused().
Press(keys.Universal.Return)
t.Views().Commits().
IsFocused().
SelectNextItem().
SelectedLine(Contains("one")).
Press(keys.Universal.FocusMainView)
// Every file of the diff is offered, in the order the diff shows them and by
// the path the repo knows them by, including the ones below the part of the
// diff that has been read.
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.JumpToFile)
t.ExpectPopup().Menu().
Title(Equals("Jump to file")).
Lines(
Equals("aaa.txt"),
Equals("ccc.txt"),
Equals("dir/bbb.txt"),
Equals("zzz.txt"),
Equals("Cancel"),
).
Select(Equals("dir/bbb.txt")).
Confirm()
// The file lands where stepping to it with next-file would leave it: selected,
// and at the top of the view.
t.Views().Main().
IsFocused().
TopVisibleLine(Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt")).
SelectedLines(
Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt"),
).
Press(keys.Universal.JumpToFile)
// The menu filters as you type, which is the point of it for a diff of many
// files.
t.ExpectPopup().Menu().
Title(Equals("Jump to file")).
Filter("ccc").
Lines(
Equals("ccc.txt"),
).
Confirm()
t.Views().Main().
IsFocused().
SelectedLines(
Contains("diff --git a/ccc.txt b/ccc.txt"),
).
Press(keys.Universal.Return)
// The panel the diff belongs to offers the same menu, so that a file can be
// jumped to without focusing the diff and having to come back out of it.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one")).
Press(keys.Universal.JumpToFile)
t.ExpectPopup().Menu().
Title(Equals("Jump to file in diff")).
Select(Equals("zzz.txt")).
Confirm()
// Only the diff moves: it goes to the top of the file, while the panel keeps
// the focus and its selection.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one"))
t.Views().Main().
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
// A panel showing something else in the main view — a branch's commit log —
// doesn't offer the menu at all, rather than offering it disabled.
t.Views().Branches().
Focus().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(DoesNotContain("Jump to file"))
}).
Cancel()
t.Views().Commits().
Focus().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(Contains("Jump to file in diff"))
}).
Cancel()
},
})
@@ -1,74 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var JumpToAFileOnlyOverADiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The menu of the diff's files is offered only while the main view is showing a diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shared.CreateMergeConflictFiles(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit shows its diff in the main view, so the menu is offered over it.
t.Views().Commits().
Focus().
NavigateToLine(Contains("original")).
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(Contains("Jump to file in diff"))
}).
Cancel()
// The pane binds the same key itself, so the global one isn't offered on top of
// the pane's while the pane has the focus.
t.Views().Commits().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().
Content(Contains("Jump to file")).
Content(DoesNotContain("Jump to file in diff"))
}).
Cancel()
t.Views().Main().
IsFocused().
Press(keys.Universal.Return)
// Working through a conflicted file gives the main section over to the merge
// conflicts view, which is no diff to jump around in. The pane behind it goes
// on holding the diff it last rendered, so it is the view on screen that
// decides.
t.Views().Files().
Focus().
NavigateToLine(Contains("UU file1")).
PressEnter()
t.Views().MergeConflicts().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(DoesNotContain("Jump to file"))
}).
Cancel()
},
})
@@ -1,54 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoDiffStatLinksUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The files named in the diffstat are not linked under a diff renderer that says nothing about its rows",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
// An external diff whose output has nothing to say about which line of which
// file each row shows. git writes the diffstat itself, so the names are there
// to be clicked, but nothing could find the file they name.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Name: "opaque", Type: "extDiff", Command: `sh -c 'echo EXT'`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("aaa.txt", "one\n")
shell.CreateFileAndAdd("zzz.txt", "one\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("one"))
// The click below is at a line of the diffstat, so the diff has to open with
// the lines this expects.
t.Views().Main().
TopLines(
Contains("commit"),
Contains("Author:"),
Contains("Date:"),
Equals(""),
Contains("one"),
Equals("---"),
Contains("aaa.txt"),
Contains("zzz.txt"),
Contains("2 files changed"),
).
Click(2, 7)
// The name is no link, so the click is an ordinary one, which focuses the pane
// it lands in. Were it a link, it would have been followed instead, and would
// have had to report that it found no such file — the test fails on the toast
// that leaves unacknowledged.
t.Views().Main().IsFocused()
},
})
@@ -11,7 +11,6 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowFileTree = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
@@ -48,20 +47,6 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
PressPrimaryAction().
Tap(func() {
t.ExpectToast(Contains("There is nothing to select here"))
}).
// A pane with nothing to select still has lines to point at. A modified
// click names its own line rather than acting on the selection, so it
// opens the file there. The file is in the working tree for a conflict
// like this one, holding the modified side; you may want to copy a piece
// of it elsewhere before resolving the conflict by deleting the file.
//
// The cursor moves where a plain click points even here, so the assertion
// below says which row the modified click then lands on.
Click(0, 17).
SelectedLine(Contains("+2")).
AltClick(0, 17).
Tap(func() {
t.FileSystem().FileContent("edit-command", Contains("/repo/bar:1\n"))
})
},
})
@@ -1,105 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var OpenPullRequestOnlyOverACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Opening a diff line in the pull request is offered over a commit's own diff, and refused over the other diffs the main view shows",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("first commit")
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("second commit")
shell.UpdateFile("file1", "one\nTWO\nTHREE\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// The working tree's diff is no commit of a branch, so no pull request has a
// view of it and the command isn't offered there at all.
t.Views().Files().
Focus().
SelectedLine(Contains("file1")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
// The command is bound right below the one asserted here, so a menu
// showing that one would be showing this one too if it had it.
t.Views().Menu().
Content(Contains("Go to next file")).
Content(DoesNotContain("Open pull request at selected line"))
}).
Cancel()
// Over a commit's diff it is offered, and says so where the branch has no pull
// request to open.
t.Views().Commits().
Focus().
SelectedLine(Contains("second commit")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-two"),
).
Press(keys.Commits.OpenPullRequestInBrowser)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("No pull request found for this branch")).
Confirm()
// The pane previewing the custom patch shows the patch's lines at the numbers
// the patch gives them, which are not the ones the pull request shows.
t.Views().Main().
IsFocused().
PressPrimaryAction().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("-two"),
).
Press(keys.Commits.OpenPullRequestInBrowser)
t.ExpectToast(Contains("Not available for the custom patch"))
// In diffing mode the main view shows a diff against another ref rather than
// the commit's own, and the pull request has no view of that either.
t.Views().Commits().
Focus().
Press(keys.Universal.DiffingMenu)
t.ExpectPopup().Menu().
Title(Equals("Diffing")).
Select(MatchesRegexp(`Diff \w+`)).
Confirm()
t.Views().Commits().
SelectNextItem().
SelectedLine(Contains("first commit")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Commits.OpenPullRequestInBrowser)
t.ExpectToast(Contains("Not available in diffing mode"))
},
})
-7
View File
@@ -378,8 +378,6 @@ var tests = []*components.IntegrationTest{
main_view.BuildPatchWithMixedSelections,
main_view.ChangeContextSizeWhileBuildingPatch,
main_view.ChangeScreenModeInFocusedDiff,
main_view.ClickAFileInADiffStatThatComesFirst,
main_view.ClickAFileInTheDiffStat,
main_view.ClickSelectsDiffLine,
main_view.CommitFromMainView,
main_view.CopyRowsThatAreNoDiffLine,
@@ -393,7 +391,6 @@ var tests = []*components.IntegrationTest{
main_view.DiscardLinesFromACommit,
main_view.DragRangeWithAutoscroll,
main_view.DragSelectsDiffLineRange,
main_view.EditClickedDiffLine,
main_view.EditHistoricalDiffLine,
main_view.EditHunkInFocusedDiff,
main_view.EditSelectedDiffLine,
@@ -407,8 +404,6 @@ var tests = []*components.IntegrationTest{
main_view.FocusLeavesAnAlwaysSplitEmptyPane,
main_view.FocusReturnsWhenSplitCollapses,
main_view.HideSelectionWhenChangesVanish,
main_view.JumpToAFileOfTheDiff,
main_view.JumpToAFileOnlyOverADiff,
main_view.KeepAWrappedLineCoveredAcrossARerender,
main_view.KeepBothHalvesOfAChangeSelected,
main_view.KeepPositionByTheVisibleEndOfASelection,
@@ -444,12 +439,10 @@ var tests = []*components.IntegrationTest{
main_view.MovePatchToNewCommitBefore,
main_view.MovePatchToNewCommitInStackedBranch,
main_view.NavigateByHunkAndFile,
main_view.NoDiffStatLinksUnderAnExternalDiff,
main_view.NoSelectionOverABinaryDiff,
main_view.NoSelectionOverACommitLog,
main_view.NoSelectionOverAConflictHint,
main_view.NoSelectionWhenNoChanges,
main_view.OpenPullRequestOnlyOverACommitsDiff,
main_view.PatchMarksFollowARendererSwitch,
main_view.PatchMarksShowWheneverTheirDiffIsOnScreen,
main_view.RangeSelectDiffLines,
-3
View File
@@ -28,9 +28,6 @@ type GuiDriver interface {
// user typing faster than lazygit processes the input.
PressKeysRapidly(...string)
Click(int, int)
// Click with a keyboard modifier held down, for the gestures that only exist
// as a modified click.
ClickWithModifier(int, int, gocui.Modifier)
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)
-24
View File
@@ -1221,16 +1221,6 @@
"description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.",
"default": true
},
"commitGraphStyle": {
"type": "string",
"enum": [
"auto",
"classic",
"detailed"
],
"description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.",
"default": "auto"
},
"commitAuthorShortLength": {
"type": "integer",
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
@@ -3138,20 +3128,6 @@
],
"default": "0"
},
"jumpToFile": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "\u003cctrl+g\u003e"
},
"nextMatch": {
"oneOf": [
{
-3
View File
@@ -12,9 +12,6 @@ gui:
- black
# Not important in tests but it creates clutter in demos
showRandomTip: false
# The expected output of the tests draws the commit graph with box drawing
# characters, whichever terminal a test runs in
commitGraphStyle: classic
animateExplosion: false # takes too long
git:
# We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.