Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 7d0ebc9d09 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 633e420f91 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 5e559cabec 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 228090232f 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 3c76e7c035 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 5f6e9b1ef3 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 b9eaf569ad 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 1d7db6cd0b 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 403c1d20f0 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 b7a51e9270 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 b5dcf4c076 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 e2101a94f2 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-04 19:01:02 +02:00
Stefan HallerandClaude Opus 5 1e61fd8465 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-04 19:01:02 +02:00
Stefan Haller 079b9878bf Fix AGENTS.md markdown syntax
VS Code changes *italics* to _italics_ when saving, so normalize these
once.
2026-10-04 19:01:02 +02:00
Stefan Haller 43b90835b7 AGENTS.md additions 2026-10-04 19:01:02 +02:00
82 changed files with 3327 additions and 176 deletions
+34 -6
View File
@@ -63,7 +63,9 @@ Prefer a fine-grained commit history. Commits should be as small as possible
while still being meaningful and self-contained.
- **Every commit must compile and pass all tests.** No "WIP" commits, no
commits that leave the tree broken and rely on a follow-up to fix it.
commits that leave the tree broken and rely on a follow-up to fix it. A
`fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
what one may leave broken until it is folded in.
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
committing.
- **Every commit must be lint-clean.** Run `just lint` before committing —
@@ -134,6 +136,32 @@ target, make the change, `git commit --fixup=<target>`, then
branch. The fixup stays a separate, reviewable commit; only its position
changes.
**A fixup may leave commits before it broken until it is folded in.** If a
`fixup!` on an early commit deletes something that a later commit still uses,
the later commit doesn't build until its own `fixup!`, right behind it, catches
up; the same goes for lint. That is expected. The rules above about every
commit compiling, testing and linting clean describe the history _after_
autosquash, and I fold fixups in soon after reviewing them. Never amend a
commit directly, or edit the commits between two fixups, to keep every commit
of the un-squashed history green. The reviewable fixup is worth more than a
green intermediate state. Verify at each fixup instead, since the tree there
is what the folded-in history will have at that point, and say in the handoff
which commits stay broken until which fixup.
**After a mid-stack fixup, check every branch tip above it, not just the stack
tip.** A fixup that deletes or renames something rewrites every commit replayed
above it, and a commit further up can hide the damage at the tip. A helper
whose last caller the fixup deleted is flagged as unused by `just lint` at the
tip of its own PR, but a later PR that calls it again makes the stack tip lint
clean. Each PR is reviewed and merged on its own, so each PR branch tip has to
be green on its own. After the replay, run `just build`, `just unit-test` and
`just lint` at each branch tip from the insertion point up. If the fixup deleted
or renamed a symbol, also build every replayed commit, for example with
`git -c rebase.autosquash=false rebase -x 'go build ./...' <insertion point>`;
unchanged commits are fast-forwarded, so their hashes stay, and the commits a
fixup is expected to leave broken stop it, so `git rebase --continue` past
those.
If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to
@@ -180,7 +208,7 @@ looks messy. The whole point of a fixup is that the iteration stays
**visible and reviewable**; squashing it away yourself destroys exactly the
artifact it exists to create. Collapsing fixups into their targets is the
user's action, taken once they've reviewed the iterations. Every mention of
`--autosquash` in this section describes what the *user* will eventually
`--autosquash` in this section describes what the _user_ will eventually
run, never a step for you to perform. If you think the history is ready to
collapse, say so and leave it to them.
@@ -320,7 +348,7 @@ refactor to an earlier commit (but don't do it without asking first).
## Don't read model state right after a `Refresh`
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
_enqueues_ the model update onto the UI thread. So when `Refresh` returns, the
model is **not** updated yet — the write is still queued. Reading a field
synchronously right after refreshing its scope reads the stale, pre-refresh
value (and this is true even for SYNC refreshes):
@@ -406,7 +434,7 @@ column. Applies only to `pkg/i18n/english.go`.
## Code comments are for future readers, not development history
Comments in source code explain *why this code is shaped the way it is*. They
Comments in source code explain _why this code is shaped the way it is_. They
are not the place to narrate the path we took during development — what was
tried first, what didn't work, what's "more reliable" or "cleaner" than some
alternative. That framing is interesting in the moment, but it's noise to
@@ -422,7 +450,7 @@ Avoid phrasings like:
- "X rather than Y", where Y is what the code did before the change
The iteration story is sometimes worth preserving — but it belongs in the
commit message, which is the durable record of *why this change was made*. The
commit message, which is the durable record of _why this change was made_. The
code comment should make sense to someone who has never seen any prior version
and is just trying to understand the file as it currently exists.
@@ -475,7 +503,7 @@ So:
struct, run `just generate` and include the regenerated
`docs-master/Config.md` (and `schema-master/config.json`) in your commit.
- Don't hard-wrap the doc comments on `userConfig` fields. This applies
*only* to `userConfig`, because those comments are fed through the doc
_only_ to `userConfig`, because those comments are fed through the doc
generator; comments on every other struct follow the normal Go wrapping
conventions. For `userConfig` fields, write each sentence (or paragraph)
as a single unwrapped line, however long — the generator re-wraps them for
@@ -226,6 +226,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
@@ -338,6 +339,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Toggle range select | |
| `` e `` | Edit file | Open file in external editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
@@ -195,6 +195,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
@@ -315,6 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
@@ -87,6 +87,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
@@ -172,6 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
@@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
@@ -316,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
| `` v `` | Toggle drag selecteer | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <ctrl+o> `` | Copy selected text to clipboard | |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
@@ -102,6 +102,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
@@ -211,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Przełącz zaznaczenie zakresu | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
@@ -238,6 +238,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
@@ -347,6 +348,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
| `` v `` | Toggle range select | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
@@ -77,6 +77,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
@@ -116,6 +117,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
@@ -289,6 +289,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
@@ -344,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
@@ -78,6 +78,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
@@ -366,6 +367,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
+86 -6
View File
@@ -637,6 +637,13 @@ func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
// view showing a plain cursor. A range whose ends are on the same view line is still
// one, which SelectedLineRange alone can't tell you.
func (v *View) HasRangeSelect() bool {
return v.rangeSelectStartY != -1
}
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
if viewHeight >= lineCount {
return 0
@@ -1477,9 +1484,9 @@ func (v *View) CopyContent(from *View) {
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
// different view than the destination — its callers hand content from one
// view to another — and no other code holds two view write locks at once, so
// this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
@@ -1996,9 +2003,60 @@ func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
contents := make([]DiffLineContent, len(v.buf.lines))
for i := range v.buf.lines {
line := &v.buf.lines[i]
return diffLineContentsFrom(v.buf, 0)
}
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
// progress (see BeginOffscreenRender). A reader deciding where the new content
// should be shown has to work from this: it has to answer before the swap, since
// after the swap the content is already on screen. Returns nil when no re-render
// is underway.
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return nil
}
return diffLineContentsFrom(v.offscreen, 0)
}
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
// follows a re-render as it loads look at each line once, rather than snapshotting
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
// past the lines read so far.
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
return nil
}
return diffLineContentsFrom(v.offscreen, from)
}
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
// has read so far, or 0 when none is underway. It tells a reader waiting for a
// particular line, cheaply, when a screenful below it has arrived too — so that the
// swap shows that line with content under it rather than at the bottom edge of a
// half-filled view.
func (v *View) OffscreenLineCount() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return 0
}
return len(v.offscreen.lines)
}
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
lines := buf.lines[from:]
contents := make([]DiffLineContent, len(lines))
for i := range lines {
line := &lines[i]
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
@@ -2288,6 +2346,11 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// IsLineVisible reports whether the given view line is one of those on screen.
func (v *View) IsLineVisible(viewLine int) bool {
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
@@ -2380,6 +2443,23 @@ func (v *View) SelectedLineRange() (int, int) {
return start, end
}
// SelectedBufferLineRange is SelectedLineRange in buffer lines (see
// BufferLineForViewLine): the first and last line of the view's content that the
// selection covers, however the view wraps them. ok is false when the selection
// isn't on the content, which happens when there is none.
func (v *View) SelectedBufferLineRange() (int, int, bool) {
first, last := v.SelectedLineRange()
firstBufferLine, ok := v.BufferLineForViewLine(first)
if !ok {
return 0, 0, false
}
lastBufferLine, ok := v.BufferLineForViewLine(last)
if !ok {
return 0, 0, false
}
return firstBufferLine, lastBufferLine, true
}
func (v *View) RenderTextArea() {
v.Clear()
fmt.Fprint(v, v.TextArea.GetContent())
+20
View File
@@ -453,6 +453,26 @@ func TestLastViewLineForBufferLine(t *testing.T) {
assert.False(t, ok)
}
func TestSelectedBufferLineRange(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
_, _, ok := v.SelectedBufferLineRange()
assert.False(t, ok)
// Buffer line 1 wraps into view lines 1, 2 and 3.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
// A range from the middle of the wrapped line to the line below it covers both
// lines.
v.FocusPoint(0, 2, false)
v.SetRangeSelectStart(4)
first, last, ok := v.SelectedBufferLineRange()
assert.True(t, ok)
assert.Equal(t, 1, first)
assert.Equal(t, 2, last)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
+23 -8
View File
@@ -13,14 +13,15 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
focusedMainViewDiffSource types.FocusedMainViewDiffSource
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@@ -159,6 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.focusedMainViewDiffSource = nil
self.onRenderToMainFn = nil
}
@@ -180,6 +182,15 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
if source != nil {
if self.focusedMainViewDiffSource != nil {
panic("only one controller is allowed to set the focused main view diff source")
}
self.focusedMainViewDiffSource = source
}
}
func (self *BaseContext) GetOnDoubleClick() func() error {
return self.onDoubleClickFn
}
@@ -188,6 +199,10 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.focusedMainViewDiffSource
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
if fn != nil {
if self.onRenderToMainFn != nil {
+9 -3
View File
@@ -85,10 +85,16 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
if refs := self.GetRefRange(); refs != nil {
return refs.From.ParentRefName(), refs.To.RefName()
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
}
// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a
// range runs from the parent of its first ref to its last, a single ref from its own
// parent to itself.
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
if refRange != nil {
return refRange.From.ParentRefName(), refRange.To.RefName()
}
ref := self.GetRef()
return ref.ParentRefName(), ref.RefName()
}
+1 -1
View File
@@ -67,8 +67,8 @@ func (gui *Gui) resetHelpersAndControllers() {
worktreeHelper,
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
+1
View File
@@ -8,6 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
+4
View File
@@ -23,6 +23,10 @@ func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) e
return nil
}
func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return nil
}
func (self *baseController) GetOnRenderToMain() func() {
return nil
}
@@ -191,6 +191,18 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
}
}
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the commit's diff for the given files. Both panes show the same
// diff here — the secondary one shows the custom patch built from it, which is not a
// diff of the commit — so which pane asks makes no difference.
func (self *CommitFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
from, to := self.context().GetFromAndToForDiff()
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
}
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
from, to := self.context().GetFromAndToForDiff()
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
@@ -83,6 +83,11 @@ func (self *ContextLinesController) applyChange() error {
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
default:
// The diff is about to be rendered again with more or less context around
// each change, which reads as the lines you were looking at moving up or down
// the view; keep them where they are instead.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentContext.HandleRenderToMain()
}
return nil
+28 -22
View File
@@ -21,6 +21,9 @@ type FilesController struct {
baseController
*ListControllerTrait[*filetree.FileNode]
c *ControllerCommon
// what this panel offers on the diff it shows in the focused main view
diffActions *WorkingTreeDiffActions
}
var _ types.IController = &FilesController{}
@@ -36,6 +39,7 @@ func NewFilesController(
c.Contexts().Files.GetSelected,
c.Contexts().Files.GetSelectedItems,
),
diffActions: NewWorkingTreeDiffActions(c),
}
}
@@ -366,42 +370,44 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
self.c.Helpers().MergeConflicts.ResetMergeState()
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
// The unstaged side of a file's diff is shown in the main pane and the staged side
// in the secondary one, each only where there is a side to show — so a side is
// always in the same place, whatever the file happens to have. A file with nothing
// unstaged therefore shows its staged changes in the secondary pane, which then has
// the whole section to itself. Configured to always split, both panes are shown
// whether or not there is anything on either side.
alwaysSplit := self.c.UserConfig().Gui.SplitDiff == "always"
showStaged := node.GetHasStagedChanges() || alwaysSplit
showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
}
refreshOpts := types.RefreshMainOpts{
Pair: self.c.MainViewPairs().Normal,
Main: &types.ViewUpdateOpts{
refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal}
if showUnstaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, false, paths)
refreshOpts.Main = &types.ViewUpdateOpts{
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: title,
},
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
title = self.c.Tr.UnstagedChanges
Title: self.c.Tr.UnstagedChanges,
}
}
if showStaged {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
refreshOpts.Secondary = &types.ViewUpdateOpts{
Title: title,
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
Title: self.c.Tr.StagedChanges,
}
}
self.c.RenderToMainViews(refreshOpts)
}
func (self *FilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self.diffActions
}
func (self *FilesController) GetOnDoubleClick() func() error {
return self.withItemGraceful(func(node *filetree.FileNode) error {
return self.press([]*filetree.FileNode{node})
+23 -3
View File
@@ -14,12 +14,14 @@ import (
)
type DiffHelper struct {
c *HelperCommon
c *HelperCommon
diffLineHelper *DiffLineHelper
}
func NewDiffHelper(c *HelperCommon) *DiffHelper {
func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper {
return &DiffHelper{
c: c,
c: c,
diffLineHelper: diffLineHelper,
}
}
@@ -81,6 +83,19 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
}
// PlainDiffBetweenRefs returns the diff of the given files between two refs as git
// writes it, without colour or a diff renderer's involvement — what a panel showing
// a commit's diff hands out as the diff behind its rendering (see
// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of
// the same two ends the main view is showing.
func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string {
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
// An error means there is no diff to be had, which for our purposes is the same
// as an empty one.
diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true).RunWithOutput()
return diff
}
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
filterPath := self.c.Modes().Filtering.GetPath()
if filterPath != "" {
@@ -107,6 +122,11 @@ func (self *DiffHelper) RenderToMainAgain() {
if currentSide.GetKey() == currentKey ||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
// Whatever changed can make the diff come out differently, such as a new
// renderer laying it out its own way, so the line you were looking at could
// end up anywhere in the view; keep it in front of you.
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.diffLineHelper.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
currentSide.HandleRenderToMain()
}
}
+72 -39
View File
@@ -32,48 +32,58 @@ func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
// ok is false when the row's identity can't be recovered, in which case the
// caller must not act on the line at all.
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
return identities[0], true
}
// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view
// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't
// settle for the line the row leads with: an end of a selection covers its whole
// row, so where a rendering puts a modification's two halves side by side it covers
// both of them. ok is false when the row's identity can't be recovered at all.
func (self *DiffLineHelper) diffLineIdentitiesAt(
view *gocui.View, viewLineIdx int,
) ([]types.DiffLineInfo, bool) {
// The cursor and clicks land on a view line, which counts wrapped segments;
// the contents are indexed by unwrapped buffer line.
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
return nil, false
}
contents := view.DiffLineContents()
if bufferLineIdx >= len(contents) {
return types.DiffLineInfo{}, false
return nil, false
}
if renderingStatesDiffLines(contents) {
if info, ok := self.diffLineInfoFromRecords(contents[bufferLineIdx].Metadata); ok {
return info, true
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
return identities, true
}
return types.DiffLineInfo{}, false
return nil, false
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
if !ok {
return types.DiffLineInfo{}, false
return nil, false
}
return self.diffLineInfo(parsed), true
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
}
// diffLineInfoFromRecords recovers a row's identity from the records the diff
// renderer stated for it. ok is false when the row carries no record we understand.
//
// A row can carry more than one record, when the rendering puts two diff lines on it
// (a side-by-side row shows a deletion and the addition replacing it); the leftmost
// is the one a reader would call the row's own, so it is the row's identity.
func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) {
if len(metadata) == 0 {
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineMetadata(metadata[0])
if !ok {
return types.DiffLineInfo{}, false
}
return self.diffLineInfo(parsed), true
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
// records state, left to right. A row can carry more than one record, when the
// rendering puts two diff lines on it (a side-by-side row shows a deletion and the
// addition replacing it). Which of them a reader is after depends on the reader: the
// one the row leads with is the row's own identity (see GetDiffLineInfo and
// resolveDiffLines), while a reader looking for a particular line has to consider
// them all, since which of a modification's two halves leads a row is up to the
// rendering.
func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo {
return self.diffLineInfos(parseDiffLineRecords(metadata))
}
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
@@ -86,36 +96,59 @@ type resolvedDiffLine struct {
// resolveDiffLines recovers the identity of every row of a rendered diff in one
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
// whole-buffer scans (which change lines are where, which file each row belongs
// to), and reads the rendering the same way: by the renderer's records, or by
// parsing it as a unified diff (see renderingStatesDiffLines). Resolving row by row
// would re-run the buffer parser's whole-section parse once per row — O(n²) on a
// large single-file diff — so the buffer parser runs once for the whole buffer.
// to). A row's identity is the line it leads with, of those resolveDiffLineIdentities
// finds on it.
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
resolved := make([]resolvedDiffLine, len(contents))
if renderingStatesDiffLines(contents) {
for i, content := range contents {
if info, ok := self.diffLineInfoFromRecords(content.Metadata); ok {
resolved[i] = resolvedDiffLine{info, true}
}
}
return resolved
}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
if parsed.ok {
resolved[i] = resolvedDiffLine{self.diffLineInfo(parsed.parsed), true}
for i, identities := range self.resolveDiffLineIdentities(contents) {
if len(identities) > 0 {
resolved[i] = resolvedDiffLine{identities[0], true}
}
}
return resolved
}
// resolveDiffLineIdentities recovers every diff line each row of a rendered diff
// shows, in one pass, indexed 1:1 with contents. It reads the rendering the way
// GetDiffLineInfo does, by the renderer's records or by parsing it as a unified diff
// (see parseDiffLineIdentities), and is the form of the batch resolver for the
// readers that can't settle for the line a row leads with: looking for a remembered
// line in a new rendering has to consider both halves of a modification, since a
// side-by-side row leads with the deletion whose addition was what got remembered
// under a unified one.
func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo {
identities := make([][]types.DiffLineInfo, len(contents))
for i, parsed := range parseDiffLineIdentities(contents) {
if len(parsed) > 0 {
identities[i] = self.diffLineInfos(parsed)
}
}
return identities
}
// diffLineInfo turns a parser's result into the absolute-path identity consumers
// work with. The path arrives repo-relative from the diff header, but a renderer
// states it however it likes, absolute paths included.
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed)
}
// diffLineInfos is diffLineInfo over every line of a row.
func (self *DiffLineHelper) diffLineInfos(parsed []parsedDiffLine) []types.DiffLineInfo {
infos := make([]types.DiffLineInfo, len(parsed))
for i, line := range parsed {
infos[i] = self.diffLineInfo(line)
}
return infos
}
// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't
// ask which repo we are in where they run: a repo switch replaces it, so only the UI
// thread may read it.
func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo {
path := parsed.Path
if !filepath.IsAbs(path) {
path = filepath.Join(self.c.Git().RepoPaths.WorktreePath(), path)
path = filepath.Join(worktreePath, path)
}
return types.DiffLineInfo{
@@ -125,6 +125,46 @@ func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
})
}
// parseDiffLineRecords parses the records a row carries, left to right, leaving out
// the ones we don't understand. A row carries more than one when the rendering puts
// two diff lines on it, as a side-by-side row does with a deletion and the addition
// replacing it.
func parseDiffLineRecords(metadata []string) []parsedDiffLine {
parsed := make([]parsedDiffLine, 0, len(metadata))
for _, record := range metadata {
if line, ok := parseDiffLineMetadata(record); ok {
parsed = append(parsed, line)
}
}
return parsed
}
// parseDiffLineIdentities recovers, for every row of a rendering, the diff lines it
// shows, indexed 1:1 with contents; a row that shows none we can place gets an empty
// entry. The rendering is read the way renderingStatesDiffLines settles: by the
// renderer's records, every one a row carries, or else by parsing the rendering as a
// unified diff, where each row shows one line. Each file's section is parsed once;
// resolving row by row would re-run that parse once per row, O(n²) on a large
// single-file diff.
func parseDiffLineIdentities(contents []gocui.DiffLineContent) [][]parsedDiffLine {
identities := make([][]parsedDiffLine, len(contents))
if renderingStatesDiffLines(contents) {
for i, content := range contents {
if parsed := parseDiffLineRecords(content.Metadata); len(parsed) > 0 {
identities[i] = parsed
}
}
return identities
}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
if parsed.ok {
identities[i] = []parsedDiffLine{parsed.parsed}
}
}
return identities
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest line starting a section at or above it, up to
// where that section ends. start is -1 when targetIdx is above the first file
@@ -420,3 +420,61 @@ func TestRenderingStatesDiffLines(t *testing.T) {
})
}
}
func TestParseDiffLineIdentities(t *testing.T) {
row := func(text string, records ...string) gocui.DiffLineContent {
return gocui.DiffLineContent{Text: text, Metadata: records}
}
t.Run("a rendering with records is read by them alone", func(t *testing.T) {
// A renderer's picture of a commit that adds a test whose input is a diff. The
// test's "diff --git" line is an added line of the test's file and is shown on
// a row of its own, and the rows the renderer puts between hunks carry no
// record. Parsed as a diff, that row would open a section of a file the diff
// doesn't have and claim every untagged row below it.
contents := []gocui.DiffLineContent{
row("src/parser.rs", "1;f;;;src/parser.rs"),
row(`let input = "\`, "1;c;10;;src/parser.rs"),
row("diff --git a/img.png b/img.png", "1;a;11;;src/parser.rs"),
row("Binary files a/img.png and b/img.png differ", "1;a;12;;src/parser.rs"),
row(""),
row("fn later() {}", "1;c;40;;src/parser.rs"),
}
assert.Equal(t, [][]parsedDiffLine{
{{Path: "src/parser.rs", Type: types.DiffLineFileHeader}},
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 10}},
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 11}},
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 12}},
nil,
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 40}},
}, parseDiffLineIdentities(contents))
})
t.Run("a row with two records shows both of their lines", func(t *testing.T) {
contents := []gocui.DiffLineContent{
row("two │ TWO", "1;d;2;2;file1", "1;a;2;;file1"),
}
assert.Equal(t, [][]parsedDiffLine{
{
{Path: "file1", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2},
{Path: "file1", Type: types.DiffLineAdded, NewLine: 2},
},
}, parseDiffLineIdentities(contents))
})
t.Run("a rendering without records is parsed as a diff", func(t *testing.T) {
bufferLines := strings.Split(twoFileDiff, "\n")
contents := make([]gocui.DiffLineContent, len(bufferLines))
for i, line := range bufferLines {
contents[i] = row(line)
}
identities := parseDiffLineIdentities(contents)
for i, parsed := range parseAllDiffLinesFromBuffer(bufferLines) {
assert.True(t, parsed.ok, "line %d", i)
assert.Equal(t, []parsedDiffLine{parsed.parsed}, identities[i], "line %d", i)
}
})
}
@@ -0,0 +1,164 @@
package helpers
import (
"path/filepath"
"slices"
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// Reading a rendering back to the diff it came from. What a diff view shows is a diff
// renderer's picture of a diff, and a picture is not what you want on your clipboard,
// or in a patch — so the lines of interest are located by identity in the diff itself,
// which the panel that rendered it hands out (types.FocusedMainViewDiffSource).
// PlainDiffOfSelection returns the text of the rows a selection covers in view, the
// inclusive buffer-line range [first, last] (see DiffLinesInBufferRange), in two parts.
//
// aboveDiff is what the selection covers above the diff, as it reads on screen; see
// textAboveDiff. fromDiff is the diff behind the rest: per file the selection touches,
// the run of diff lines from the first of its selected lines to the last, with the
// files in the order the selection meets them.
//
// A run, rather than the matched lines alone, so that what comes out reads as a diff:
// the lines between two selected ones come along even when the rendering didn't show
// them (difftastic leaves out whitespace-only changes) or showed them in another order
// (a side-by-side rendering groups the deletions of a hunk before its additions).
//
// Headers are selected lines like any other. A hunk header names the first line of its
// hunk, in the rendering as in the diff, so it is looked for the way a line of the file
// is; a file header names no line at all and a rendering may spread it over as many
// rows as it likes, so a selection touching one of them takes the whole header.
//
// plainDiff fetches the diff of the given repo-relative files, and is asked only for
// the files the selection touches, so that copying three lines of a commit's diff
// doesn't fetch the whole of it. fromDiff is "" when no selected row could be found in
// the diff, e.g. because the selection covers nothing but a renderer's decoration.
func (self *DiffLineHelper) PlainDiffOfSelection(
view *gocui.View, first int, last int, plainDiff func(paths []string) string,
) (aboveDiff string, fromDiff string) {
worktreePath := self.c.Git().RepoPaths.WorktreePath()
aboveDiff = self.textAboveDiff(view, first, last)
// The files in the order they are shown, and per file what the selection covers of
// its diff.
paths := []string{}
selection := map[string]*selectedDiffLines{}
for _, info := range self.DiffLinesInBufferRange(view, first, last) {
// A row that is neither a line of the file nor a header — the "\ No newline at
// end of file" marker — names nothing to look for. It comes along anyway when it
// falls within a run.
if info.Type == types.DiffLineOther {
continue
}
selected, ok := selection[info.Path]
if !ok {
paths = append(paths, info.Path)
selected = &selectedDiffLines{lines: map[patchLine]bool{}}
selection[info.Path] = selected
}
if info.Type == types.DiffLineFileHeader {
selected.header = true
} else {
selected.lines[patchLineOf(info)] = true
}
}
relPaths := repoRelativePaths(worktreePath, paths)
if len(relPaths) == 0 {
return aboveDiff, ""
}
diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n")
runs := map[string][2]int{}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) {
if !parsed.ok {
continue
}
info := diffLineInfoIn(worktreePath, parsed.parsed)
selected := selection[info.Path]
if selected == nil || !selected.covers(info) {
continue
}
if run, ok := runs[info.Path]; ok {
runs[info.Path] = [2]int{run[0], i}
} else {
runs[info.Path] = [2]int{i, i}
}
}
text := strings.Builder{}
for _, path := range paths {
run, ok := runs[path]
if !ok {
continue
}
for _, line := range diffLines[run[0] : run[1]+1] {
text.WriteString(line)
text.WriteString("\n")
}
}
return aboveDiff, text.String()
}
// textAboveDiff returns the text of the selected rows that sit above the diff, as they
// read on screen. What a diff view shows before its first file — a commit's message and
// git's summary of it — is part of no file's diff, so there is nothing to look those
// rows up in and the rendering is all we have of them.
//
// It is "" for a view that shows no file at all: with no diff on screen there is
// nothing for the text to be above, and a rendering we couldn't read as a diff is
// exactly what we don't want on the clipboard.
func (self *DiffLineHelper) textAboveDiff(view *gocui.View, first int, last int) string {
startOfDiff := slices.IndexFunc(self.filePaths(view), func(path string) bool { return path != "" })
if startOfDiff == -1 {
return ""
}
last = min(last, startOfDiff-1)
if last < first {
return ""
}
return strings.Join(view.BufferLines()[first:last+1], "\n") + "\n"
}
// selectedDiffLines is what a selection covers of one file's diff.
type selectedDiffLines struct {
// The lines of the file to look for, hunk headers among them, by the identity that
// names them in any rendering of the diff.
lines map[patchLine]bool
// Whether the file's header is covered, in whole or in part.
header bool
}
// covers reports whether the given line of a file's diff is one the selection holds.
//
// A file header is answered for by kind rather than looked for: the two ways a row's
// identity is recovered disagree about what to call it — a parse of the diff says the
// file's first line, a renderer's record says the file has no line — and a rendering
// may show the header as one row or as ten. So a selection that touches it holds every
// line of it.
func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
if info.Type == types.DiffLineFileHeader {
return self.header
}
return self.lines[patchLineOf(info)]
}
// repoRelativePaths turns the absolute paths a diff line's identity carries into the
// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff
// renderer states the path however it likes, and one we can't place is one we can't
// ask git about.
func repoRelativePaths(worktreePath string, paths []string) []string {
relPaths := make([]string, 0, len(paths))
for _, path := range paths {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
relPaths = append(relPaths, filepath.ToSlash(relPath))
}
return relPaths
}
@@ -8,9 +8,32 @@ import (
// The questions a diff view can be asked about what it is showing — where the change
// lines are, which block or file a row belongs to — answered in the view-line terms a
// cursor and a click speak. They are all built on the identities recovered in
// cursor and a click speak, except for what a selection covers (see
// DiffLinesInBufferRange). They are all built on the identities recovered in
// diff_line_helper.go, which is where the answering stops and the recovering starts.
// DiffLinesInBufferRange returns the identity of every diff line shown by the rows in
// the inclusive buffer-line range [first, last] of view's rendered diff, in display
// order. Rows whose identity can't be recovered are left out.
//
// The range is what a selection covers (see View.SelectedBufferLineRange). It is given
// in buffer lines because a buffer line stays on its line of the content however the
// view wraps it, so the selection can be read when a key is pressed and acted on after
// the view has changed.
//
// A row can show more than one diff line — a side-by-side rendering puts a deletion
// beside the addition replacing it — and all of them are reported: what the user
// pointed at is the row, so everything on it is selected.
func (self *DiffLineHelper) DiffLinesInBufferRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
infos := []types.DiffLineInfo{}
for bufferLine := first; bufferLine <= min(last, len(identities)-1); bufferLine++ {
infos = append(infos, identities[bufferLine]...)
}
return infos
}
// changeLines resolves view's rendered diff to one flag per buffer line: whether
// that row is a change line (an addition or a deletion), as opposed to context, a
// header, or a row whose identity couldn't be recovered. Those are the rows a
@@ -0,0 +1,426 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/samber/lo"
)
// Keeping a diff view where it is when the same diff is rendered again differently.
// The line the user is on is remembered by identity (diff_line_helper.go), because a
// new rendering puts it on a different line of the view — and may not have it at all,
// which is what the fallbacks below are for.
// diffLineAnchor is a line for a restore to land on: the identity to find it by in
// the new rendering, and the screen row it was on, so that it can be put back there.
type diffLineAnchor struct {
identity types.DiffLineInfo
row int
}
// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back
// there as it next re-renders, instead of leaving the user at the top of a new
// rendering of the diff they were already reading. Call it on the view about to be
// re-rendered, right before triggering the re-render — on both panes of the main
// window where both are being rendered again, since either of them may hold the diff
// being read; a pane that isn't showing is left alone.
//
// The line to keep is the end of the selection that is on screen, and the middle
// visible line when there is no selection or the whole of it has been scrolled out of
// sight — what the user is looking at, rather than the view's top edge or a selection
// they have long since left behind. It may not survive the re-render: a context line
// goes when the context size shrinks, and a whole hunk or file goes when whitespace
// stops counting. So the lines around it come along as fallbacks and the view lands on
// the nearest one that is still there, put back on the screen row it was on. With none
// of them left — and with a renderer that says nothing about its rows there is nothing
// to look for in the first place — the view keeps the scroll offset it had, which is
// still nearer to what was being read than the top of the diff.
//
// An off-screen selection is still put back on the diff line it was on, wherever the
// new rendering has that; it is only the view that stays where it is.
//
// A range or hunk selection has a second end, which is remembered the same way, so
// that it still covers the same lines of the diff afterwards.
func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// A view that isn't the one its window is currently showing — the merge-conflicts
// view takes the main window over — isn't the one about to be re-rendered, so a
// restore installed on it would sit there and claim a later render instead.
if !view.Visible {
return
}
// The re-render is produced by a different command from the one behind what is on
// screen — another context size, another renderer — so without being told otherwise
// it would be taken for content the user has never seen and shown from the top.
// Whether or not a line of the old rendering can be found in the new one, the offset
// into it is nearer to where they were reading than the top is.
if manager := self.c.GetViewBufferManagerForView(view); manager != nil {
manager.SetKeepScrollPositionForNextTask()
}
showSelection := view.Highlight
anchorViewLine := view.MiddleVisibleLineIdx()
farEnd, hasFarEnd := types.DiffLineInfo{}, false
// A cursor that has been scrolled away from is put back by its own lines rather
// than by the anchor's, so that it comes out on the same line of the diff without
// the view having to go there.
var cursorCandidates []diffLineAnchor
if showSelection {
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
if end, ok := visibleSelectionEnd(view); ok {
anchorViewLine = end
}
if anchorViewLine != view.SelectedLineIdx() {
cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx())
}
}
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine),
func(anchor diffLineAnchor, viewLine int) {
// Put the line back on the screen row it was on, clamped into the view for
// the fallback lines, which can come from off screen.
row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1))
view.SetOrigin(0, max(0, viewLine-row))
if showSelection {
// Put the far end back before the cursor, so that the selection covers
// the same lines again; a selection whose far end didn't survive the
// re-render is left as the single line we landed on. The origin is
// already where it should be, so moving the cursor mustn't scroll.
view.CancelRangeSelect()
cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine)
if hasFarEnd {
if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok {
cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine)
view.SetRangeSelectStart(farEndViewLine)
}
}
view.FocusPoint(0, cursorViewLine, false)
}
})
}
// coverWholeLines moves the two ends of a restored selection out to the edges of the
// diff lines they are on, so that the selection covers those lines whole. Both ends
// arrive on the first view line of their diff line, which is where looking one up by
// identity lands, and the view draws a line it wraps as several — of which a
// selection of that line means all.
func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) {
if cursorViewLine <= farEndViewLine {
return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine)
}
return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine
}
// lastViewLineOfSameDiffLine returns the last view line showing the same line of the
// diff as the given one, which is that line itself unless the view wrapped it.
func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return viewLine
}
if last, ok := view.LastViewLineForBufferLine(bufferLine); ok {
return last
}
return viewLine
}
// visibleSelectionEnd returns the end of the selection to keep in place across a
// re-render: the selected line when it is on screen, and the range's other end when
// that is and the selected line isn't — a range can be long enough for the user to be
// looking at one end of it with the other far away. ok is false when the whole
// selection is off screen, and there is nothing of it to keep in place.
func visibleSelectionEnd(view *gocui.View) (int, bool) {
if view.IsLineVisible(view.SelectedLineIdx()) {
return view.SelectedLineIdx(), true
}
if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) {
return farEnd, true
}
return 0, false
}
// selectionLine returns the line to put the cursor on once a re-render is on screen:
// the line the position anchor landed on, which is the selected one whenever it was
// on screen, and otherwise the nearest surviving line to where the selection was —
// found among its own candidates, since the anchor's are a search of the diff from
// somewhere else entirely.
func (self *DiffLineHelper) selectionLine(
view *gocui.View, candidates []diffLineAnchor, anchorViewLine int,
) int {
if len(candidates) == 0 {
return anchorViewLine
}
_, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates)
if bufferLine == -1 {
return anchorViewLine
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
return viewLine
}
return anchorViewLine
}
// selectionFarEndIdentity returns the identity of the end of a range or hunk
// selection the cursor isn't on, so that a re-render can put it back. ok is false for
// a selection that is only a cursor, where restoring that is the whole job, and for
// an end that resolves to no diff line.
//
// An end covers the whole of its row, so where the row shows more than one diff line
// — a rendering that puts a modification's two halves side by side, or a word diff
// that puts both on the one line it changed — the end takes the outermost of them:
// the last for the range's lower end and the first for its upper one. Otherwise a
// rendering that splits them apart again would get back only the half the row led
// with, and half a change selected where a whole one was.
func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
farEnd, isLowerEnd, ok := selectionFarEndViewLine(view)
if !ok {
return types.DiffLineInfo{}, false
}
identities, ok := self.diffLineIdentitiesAt(view, farEnd)
if !ok {
return types.DiffLineInfo{}, false
}
if isLowerEnd {
return identities[len(identities)-1], true
}
return identities[0], true
}
// selectionFarEndViewLine returns the view line of the end of a range or hunk
// selection the cursor isn't on, and whether that is the lower of the two ends. ok
// is false when there is no range at all, only a cursor.
//
// A range whose two ends are on the same view line still has one, and is not the
// same thing as a cursor sitting there: it covers everything that row shows, which
// may be two lines of the diff at once.
func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) {
if !view.HasRangeSelect() {
return 0, false, false
}
first, last := view.SelectedLineRange()
if view.SelectedLineIdx() == first {
return last, true, true
}
return first, false, true
}
// findDiffLine returns the view line showing the given diff line in what view is
// displaying now, for placing a remembered line once the re-render is on screen.
func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)]
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(bufferLine)
}
// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the
// first of the given candidate lines the new rendering still has, calling place with
// that candidate and the view line it ended up on. The candidates are in priority
// order (see nearbyDiffLines); if the rendering has none of them, place isn't called
// and the view re-renders as it otherwise would.
//
// The nearest candidate is looked for as the content loads, so that the re-render can
// be revealed at the right position as soon as that line and a screenful below it
// have arrived. Only the nearest one, because the candidates aren't in load order: a
// farther one can load first, and landing on it while a nearer one is still on its
// way would be settling for worse. The rest are considered together once the whole
// rendering is there.
func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int),
) {
manager := self.c.GetViewBufferManagerForView(view)
if manager == nil || len(candidates) == 0 {
return
}
// The readiness check below runs on the task's own goroutine, where neither the
// view's dimensions nor the repo we are in may be read — a repo switch replaces
// the latter — so take both here, on the UI thread, for it to work from.
viewHeight := view.InnerHeight()
worktreePath := self.c.Git().RepoPaths.WorktreePath()
// What the search of the loading content has found, and how far it has looked, so
// that each line is looked at once.
found := diffLineAnchor{}
foundLine := -1
scanned := 0
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool {
if foundLine == -1 {
rows := view.OffscreenDiffLineContentsFrom(scanned)
for i, row := range rows {
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
found, foundLine = candidates[0], scanned+i
break
}
}
scanned += len(rows)
if foundLine == -1 {
return false
}
}
// Wait for a screenful below the line as well, so that the re-render isn't
// revealed with it stranded at the bottom of a half-filled view.
return view.OffscreenLineCount() >= foundLine+viewHeight
},
Apply: func(swapIn func()) bool {
anchor, bufferLine := found, foundLine
if bufferLine == -1 {
anchor, bufferLine = self.nearestSurvivingCandidate(view.OffscreenDiffLineContents(), candidates)
}
swapIn()
if bufferLine == -1 {
return false
}
viewLine, ok := view.ViewLineForBufferLine(bufferLine)
if !ok {
return false
}
place(anchor, viewLine)
return true
},
})
}
// nearbyDiffLines collects the lines of view's rendered diff as candidates for a
// restore to land on, ordered by proximity to the anchor line — the anchor itself
// first, then outward, preferring at-or-below on ties — each tagged with the screen
// row it is on. A restore lands on the first of them its re-render still has, so this
// order makes it land as near as possible to where the user was.
//
// The walk covers the whole diff rather than stopping at the change lines on either
// side of the anchor, which a context-size change always keeps: ignoring whitespace
// keeps nothing in particular, and can take a hunk or a whole file out of the diff,
// leaving the nearest surviving line in a neighbouring file.
func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return nil
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) {
return nil
}
rows := screenRows(view, len(resolved))
candidates := make([]diffLineAnchor, 0, len(resolved))
collect := func(bufferLine int) {
if line := resolved[bufferLine]; line.ok {
candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]})
}
}
collect(anchor)
for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 {
if below < len(resolved) {
collect(below)
}
if above >= 0 {
collect(above)
}
}
return candidates
}
// screenRows maps each line of view's content to the screen row it is drawn on. The
// lines above the visible ones get -1 and those below them the view's height, so that
// putting one of them back where it was lands it at the top or bottom edge.
func screenRows(view *gocui.View, bufferLineCount int) []int {
height := view.InnerHeight()
originY := view.OriginY()
rows := make([]int, bufferLineCount)
for i := range rows {
rows[i] = -1
}
lastVisible := -1
for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ {
bufferLine, ok := view.BufferLineForViewLine(y)
if !ok || bufferLine >= bufferLineCount {
continue
}
if rows[bufferLine] == -1 {
rows[bufferLine] = y - originY
}
lastVisible = bufferLine
}
for i := lastVisible + 1; i < bufferLineCount; i++ {
rows[i] = height
}
return rows
}
// nearestSurvivingCandidate returns the first of the candidates that the given
// rendering still shows, and the line of it that does. The rendering is indexed
// first, rather than searched once per candidate: the candidate list is as long as
// the diff, and so is the rendering.
func (self *DiffLineHelper) nearestSurvivingCandidate(
contents []gocui.DiffLineContent, candidates []diffLineAnchor,
) (diffLineAnchor, int) {
rows := self.patchLineRows(contents)
for _, candidate := range candidates {
if line, ok := rows[patchLineOf(candidate.identity)]; ok {
return candidate, line
}
}
return diffLineAnchor{}, -1
}
// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the
// first of its rows that does. A row can show more than one, and each is then a way
// of finding that row again.
func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int {
rows := map[patchLine]int{}
for i, identities := range self.resolveDiffLineIdentities(contents) {
for _, identity := range identities {
if _, seen := rows[patchLineOf(identity)]; !seen {
rows[patchLineOf(identity)] = i
}
}
}
return rows
}
// rowShowsDiffLine reports whether the given row of a rendering shows the given diff
// line — among any others it shows, since a side-by-side rendering puts a deletion
// beside the addition replacing it. It only knows what the renderer states about the
// row, since the alternative, parsing the rendering as a diff, needs whole hunks and
// this is asked of content that is still loading. It takes the repo's worktree path
// rather than reading it, being asked off the UI thread.
func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool {
return lo.SomeBy(row.Metadata, func(record string) bool {
parsed, ok := parseDiffLineMetadata(record)
return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target)
})
}
// patchLine records what stays the same about a diff line when the same diff is
// rendered again differently: which file it belongs to, the line number that
// identifies it on the side it belongs to, and what kind of line it is.
type patchLine struct {
path string
// Every kind of content line collapses into DiffLineContext, since an addition
// and the context line it turns into when whitespace stops counting are the same
// line of the same file. The header rows keep their kind: a file's header and the
// first line of the file it heads are not the same place.
kind types.DiffLineType
// The old file's line number for a deletion, since two consecutive deletions
// share a new-file position and differ only here; the new file's otherwise.
line int
isDeletion bool
}
func patchLineOf(info types.DiffLineInfo) patchLine {
switch info.Type {
case types.DiffLineFileHeader, types.DiffLineHunkHeader:
return patchLine{path: info.Path, kind: info.Type, line: info.NewLine}
case types.DiffLineDeleted:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true}
default:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
}
}
@@ -60,7 +60,7 @@ type WindowArrangementArgs struct {
ContentHeightForWindow func(window string) int
// Whether the main panel is split (as is the case e.g. when a file has both
// staged and unstaged changes)
SplitMainPanel bool
MainPanes types.MainPanes
// The current screen mode (normal, half, full)
ScreenMode types.ScreenMode
// The content shown on the bottom left of the screen when showing a loader
@@ -103,7 +103,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
ContentHeightForWindow: func(window string) int {
return self.windowHelper.GetContextForWindow(window).TotalContentHeight()
},
SplitMainPanel: repoState.GetSplitMainPanel(),
MainPanes: repoState.GetMainPanes(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
@@ -215,36 +215,27 @@ func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
}
func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
// if we're not in split mode we can just show the one main panel. Likewise if
// the main panel is focused and we're in full-screen mode
if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") {
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
mainPane := &boxlayout.Box{Window: "main", Weight: 1}
secondaryPane := &boxlayout.Box{Window: "secondary", Weight: 1}
switch args.MainPanes {
case types.MainPaneOnly:
return []*boxlayout.Box{mainPane}
case types.SecondaryPaneOnly:
return []*boxlayout.Box{secondaryPane}
case types.BothMainPanes:
// In full-screen mode the focused one takes the whole section anyway.
if args.ScreenMode == types.SCREEN_FULL {
if args.CurrentWindow == "main" {
return []*boxlayout.Box{mainPane}
}
if args.CurrentWindow == "secondary" {
return []*boxlayout.Box{secondaryPane}
}
}
}
if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL {
return []*boxlayout.Box{
{
Window: "secondary",
Weight: 1,
},
}
}
return []*boxlayout.Box{
{
Window: "main",
Weight: 1,
},
{
Window: "secondary",
Weight: 1,
},
}
return []*boxlayout.Box{mainPane, secondaryPane}
}
func getMidSectionWeights(args WindowArrangementArgs) (int, int) {
@@ -382,7 +373,7 @@ func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
}
func splitMainPanelSideBySide(args WindowArrangementArgs) bool {
if !args.SplitMainPanel {
if args.MainPanes != types.BothMainPanes {
return false
}
@@ -35,7 +35,7 @@ func TestGetWindowDimensions(t *testing.T) {
// Each panel shows its first tab by default; for the special-cased
// panels (status, stash) the view name matches the window name.
ActiveViewForWindow: func(window string) string { return window },
SplitMainPanel: false,
MainPanes: types.MainPaneOnly,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
@@ -79,6 +79,13 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
Handler: self.copySelection,
Description: self.c.Tr.CopySelectedTextToClipboard,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
Handler: self.prevChangeBlock,
@@ -248,6 +255,66 @@ func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeNone
}
// diffSource returns the panel beneath the focused main view, as the thing that can
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
// panel beneath shows no diff.
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
if !self.c.Context().IsInStack(self.context) {
return nil
}
sidePanel := self.c.Context().NextInStack(self.context)
if sidePanel == nil {
return nil
}
return sidePanel.GetFocusedMainViewDiffSource()
}
// copySelection copies the selected diff lines to the clipboard — not as the diff
// renderer drew them, but as they read in the diff itself, which is both what you meant
// to copy and the only form a renderer can't have mangled. A selection that is all
// additions or all deletions loses its +/- column, so that it can be pasted straight
// into code.
//
// The rows above the diff (a commit's message, git's summary of it) belong to no file,
// so they are copied as they stand on screen.
func (self *MainViewController) copySelection() error {
text := self.textOfSelection()
if text == "" {
self.c.ErrorToast(self.c.Tr.SelectionNotFoundInDiffToast)
return nil
}
self.c.LogAction(self.c.Tr.Actions.CopySelectedTextToClipboard)
if err := self.c.OS().CopyToClipboard(text); err != nil {
return err
}
self.c.Toast(self.c.Tr.SelectedDiffLinesCopiedToast)
return nil
}
// textOfSelection is what copying the selection puts on the clipboard. It is "" when
// none of the selected rows could be placed in the diff, which is what a rendering's
// own decoration comes to.
func (self *MainViewController) textOfSelection() string {
source := self.diffSource()
if source == nil {
return ""
}
view := self.context.GetView()
first, last, ok := view.SelectedBufferLineRange()
if !ok {
return ""
}
aboveDiff, fromDiff := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last,
func(paths []string) string { return source.PlainDiff(self.context, paths) })
if aboveDiff == "" {
// Only text that is all diff has a +/- column to lose: a line of a commit message
// may begin with a '-' without being a deletion of anything.
fromDiff = dropDiffPrefix(fromDiff)
}
return aboveDiff + fromDiff
}
// diffSelectState returns this pane's diff selection mode state.
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
return self.context.DiffSelectState()
@@ -37,6 +37,20 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
return self.c.Contexts().ReflogCommits
}
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the reflog entry's diff for the given files — the same diff its
// main view shows, only without the entry's message and stat above it.
func (self *ReflogCommitsController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
commit := self.context().GetSelected()
if commit == nil {
return ""
}
return self.c.Helpers().Diff.PlainDiffBetweenRefs(commit.ParentRefName(), commit.RefName(), paths)
}
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
return func() {
self.c.Helpers().Diff.WithDiffModeCheck(func() {
@@ -4,6 +4,7 @@ import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -64,6 +65,22 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
}
}
func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
return self
}
// PlainDiff hands out the diff of the panel's selected commit (or range of them) for
// the given files — the same diff its main view shows, only without the commit's
// message and stat above it.
func (self *SwitchToDiffFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
ref := self.context.GetSelectedRef()
if ref == nil {
return ""
}
from, to := context.FromAndToForDiff(ref, self.context.GetSelectedRefRangeForDiffFiles())
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
}
func (self *SwitchToDiffFilesController) enter() error {
ref := self.context.GetSelectedRef()
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
@@ -70,7 +70,13 @@ func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewM
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
return self.focusMainView(self.c.Contexts().Normal, -1)
// Usually the main pane, but the content can be in the secondary one alone: a file
// with nothing but staged changes shows them there.
mainViewContext := self.c.Contexts().Normal
if self.c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly {
mainViewContext = self.c.Contexts().NormalSecondary
}
return self.focusMainView(mainViewContext, -1)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
@@ -27,6 +27,12 @@ func (self *ToggleWhitespaceAction) Call() error {
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
// You toggle this to see whether what you are looking at is more than
// reindentation, so that is the thing to keep in front of you — even though
// ignoring whitespace, unlike the other ways of re-rendering a diff, can take
// the line away entirely along with the hunk or file it was in.
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
self.c.Context().CurrentSide().HandleRenderToMain()
return nil
}
@@ -0,0 +1,44 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// WorkingTreeDiffActions implements what the files panel offers on the diff it renders
// into the focused main view: the diff itself, for the commands that need to read lines
// out of it rather than off the screen.
type WorkingTreeDiffActions struct {
c *ControllerCommon
}
var _ types.FocusedMainViewDiffSource = &WorkingTreeDiffActions{}
func NewWorkingTreeDiffActions(c *ControllerCommon) *WorkingTreeDiffActions {
return &WorkingTreeDiffActions{c: c}
}
func (self *WorkingTreeDiffActions) context() *context.WorkingTreeContext {
return self.c.Contexts().Files
}
// PlainDiff hands out the working tree's diff for the given files, taken from the
// side of the index that the asking pane shows.
func (self *WorkingTreeDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
node := self.context().GetSelected()
if node == nil {
return ""
}
// An error means there is no diff to be had, which for our purposes is the same as
// an empty one.
diff, _ := self.c.Git().WorkingTree.
WorktreeFileDiffCmdObj(node, true, self.showsStagedSide(pane), paths).
RunWithOutput()
return diff
}
// showsStagedSide reports whether the given main pane is the one showing the staged
// side of a file's diff, which is always the lower one.
func (self *WorkingTreeDiffActions) showsStagedSide(pane types.DiffPaneContext) bool {
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
}
+13 -12
View File
@@ -22,12 +22,7 @@ func (gui *Gui) scrollDownView(view *gocui.View) {
}
func (gui *Gui) scrollUpMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
// although we have this same logic in the controller, this method can be invoked
@@ -43,12 +38,7 @@ func (gui *Gui) scrollUpMain() error {
}
func (gui *Gui) scrollDownMain() error {
var view *gocui.View
if gui.c.Context().Current().GetWindowName() == "secondary" {
view = gui.secondaryView()
} else {
view = gui.mainView()
}
view := gui.mainSectionView()
if view.Name() == "mergeConflicts" {
gui.State.Contexts.MergeConflicts.SetUserScrolling(true)
@@ -59,6 +49,17 @@ func (gui *Gui) scrollDownMain() error {
return nil
}
// mainSectionView returns the view that the keys for scrolling the main section act
// on: the pane the focus is in when it is in one of them, and otherwise the pane the
// section is showing — which is the lower one whenever it has the section to itself.
func (gui *Gui) mainSectionView() *gocui.View {
if gui.c.Context().Current().GetWindowName() == "secondary" ||
gui.State.MainPanes == types.SecondaryPaneOnly {
return gui.secondaryView()
}
return gui.mainView()
}
func (gui *Gui) mainView() *gocui.View {
viewName := gui.helpers.Window.GetViewNameForWindow("main")
view, _ := gui.g.View(viewName)
+9 -5
View File
@@ -101,6 +101,10 @@ type Gui struct {
// this tells us whether our views have been initially set up
ViewsSetup bool
// the label for the key that focuses the main view, worn by whichever of the two
// main panes that key focuses (see showFocusMainViewJumpLabelOn)
focusMainViewJumpLabel string
Views types.Views
// Log of the commands/actions logged in the Command Log panel.
@@ -231,7 +235,7 @@ type GuiRepoState struct {
Model *types.Model
Modes *types.Modes
SplitMainPanel bool
MainPanes types.MainPanes
SearchState *types.SearchState
// Lets us not load everything at once. Written and read from refresh
@@ -321,12 +325,12 @@ func (self *GuiRepoState) GetSearchState() *types.SearchState {
return self.SearchState
}
func (self *GuiRepoState) SetSplitMainPanel(value bool) {
self.SplitMainPanel = value
func (self *GuiRepoState) SetMainPanes(value types.MainPanes) {
self.MainPanes = value
}
func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel
func (self *GuiRepoState) GetMainPanes() types.MainPanes {
return self.MainPanes
}
func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error {
+77 -4
View File
@@ -110,8 +110,13 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
gui.moveMainContextPairToTop(opts.Pair)
panes := mainPanesFor(opts)
gui.handOverMainSection(opts.Pair, panes)
if opts.Main != nil {
gui.RefreshMainView(opts.Main, opts.Pair.Main)
} else {
gui.clearMainView(opts.Pair.Main)
}
if opts.Secondary != nil {
@@ -134,7 +139,50 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
}
}
gui.splitMainPanel(opts.Secondary != nil)
gui.setMainPanes(panes)
}
// handOverMainSection carries the content of the main section from the pane that has
// been showing it on its own to the pane about to, when a render moves the section's
// content from one to the other — a file's changes going from unstaged to staged, say.
//
// The section is one region of the screen to the user, so a change of which pane holds
// it has to look like that region re-rendering rather than blanking and filling in
// again: the incoming pane shows what the outgoing one was showing, where it was
// showing it, until its own render has read enough to be swapped in. It renders from
// the top when it does, the content it took over not being its own (see
// clearMainView).
func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) {
// The lower pane is always the same view, being the only one a render can leave
// holding the section on its own; the upper one is whichever view of the main
// window this render is for, which moveMainContextPairToTop has just given a copy
// of what that window was showing.
upper, lower := pair.Main.GetView(), gui.Views.Secondary
var from, to *gocui.View
switch {
case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly:
from, to = upper, lower
case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly:
from, to = lower, upper
default:
return
}
gui.g.CopyContent(from, to)
}
// mainPanesFor says which panes the given render occupies: the one it has content for,
// or both when it has content for both.
func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
switch {
case opts.Secondary == nil:
return types.MainPaneOnly
case opts.Main == nil:
return types.SecondaryPaneOnly
default:
return types.BothMainPanes
}
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
@@ -162,10 +210,19 @@ func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
}
// clearMainView empties a pane that is being given nothing to show, selection and all.
//
// An emptied pane is showing nothing, so it also goes back to the top and stops
// claiming the render it was showing: whatever it is given next is content the user
// hasn't seen there, and is shown from the top like any other.
func (gui *Gui) clearMainView(mainContext types.Context) {
mainContext.GetView().Clear()
view := mainContext.GetView()
view.Clear()
view.SetOrigin(0, 0)
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getViewBufferManagerForView(view); manager != nil {
manager.ForgetRenderedContent()
}
}
// updateDiffSelectionVisibility works out whether a main pane holds anything for a
@@ -289,8 +346,24 @@ func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
return nil
}
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
gui.State.SplitMainPanel = splitMainPanel
func (gui *Gui) setMainPanes(panes types.MainPanes) {
gui.State.MainPanes = panes
// The label for the key that focuses the main view belongs on the pane that key
// focuses, which is the secondary one while it is the only one shown.
if panes == types.SecondaryPaneOnly {
gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary)
} else {
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
}
}
// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and
// takes it off the other one, so that only the pane the key focuses wears it.
func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) {
gui.Views.Main.TitlePrefix = ""
gui.Views.Secondary.TitlePrefix = ""
view.TitlePrefix = gui.focusMainViewJumpLabel
}
// reApplySearch runs a search the view holds again over the content a render has just
+2 -2
View File
@@ -461,8 +461,8 @@ type IRepoStateAccessor interface {
SetScreenMode(ScreenMode)
InSearchPrompt() bool
GetSearchState() *SearchState
SetSplitMainPanel(bool)
GetSplitMainPanel() bool
SetMainPanes(MainPanes)
GetMainPanes() MainPanes
GetMergeOrRebaseStartedInLazygit() bool
SetMergeOrRebaseStartedInLazygit(bool)
}
+24
View File
@@ -106,6 +106,10 @@ type IBaseContext interface {
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
// Likewise for the focused main view, which acts on the diff of whichever panel
// is beneath it and so has to reach that panel's controller. nil for a panel
// that shows no diff.
AddFocusedMainViewDiffSource(FocusedMainViewDiffSource)
AddOnRenderToMainFn(func())
AddOnFocusFn(func(OnFocusOpts))
@@ -220,6 +224,21 @@ type DiffPaneContext interface {
DiffSelectState() *DiffSelectState
}
// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it
// renders into the focused main view: the diff of the given files as git writes it,
// with no colour and no diff renderer in the way. What the main view shows is that
// same diff after a renderer has had it, which may have restructured, reordered or
// dropped parts of it — so anything that needs the diff itself, rather than a picture
// of it, asks the panel that produced it.
//
// paths are repo-relative, and are asked for rather than assumed so that a few lines
// of a commit's diff can be had without fetching the whole thing. pane says which of
// the two main panes is asking, since a panel can show a different diff in each — the
// files panel shows the unstaged changes in one and the staged ones in the other.
type FocusedMainViewDiffSource interface {
PlainDiff(pane DiffPaneContext, paths []string) string
}
type IListContext interface {
Context
@@ -315,6 +334,11 @@ type HasKeybindings interface {
// HandleFocus has already been called (so the main view is up to date). Should return nil if it
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to hand out the diff behind what your
// panel renders into the focused main view, for the commands that act on a
// selection in it. nil for a controller whose panel shows no diff.
GetFocusedMainViewDiffSource() FocusedMainViewDiffSource
}
type IController interface {
+8
View File
@@ -40,3 +40,11 @@ type DiffLineInfo struct {
func (self DiffLineInfo) IsChange() bool {
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
}
// IsContent reports whether the row is a line of the file itself — a change or a
// context line — as opposed to a header or a marker. Those are the rows that have a
// position in the file, and so can be looked for in another rendering of the same
// diff, or in the diff itself.
func (self DiffLineInfo) IsContent() bool {
return self.IsChange() || self.Type == DiffLineContext
}
+13
View File
@@ -13,6 +13,19 @@ func NewMainContextPair(main Context, secondary Context) MainContextPair {
return MainContextPair{Main: main, Secondary: secondary}
}
// MainPanes says which of the two panes of the main section are shown. Most content
// takes the main pane alone; content with two sides to it — the working tree's
// unstaged and staged changes, a commit's diff and the patch built from it — takes
// both; and content whose only side is the second one takes the secondary pane alone,
// so that it has the whole section rather than sitting under an empty pane.
type MainPanes int
const (
MainPaneOnly MainPanes = iota
BothMainPanes
SecondaryPaneOnly
)
type MainViewPairs struct {
Normal MainContextPair
MergeConflicts MainContextPair
+3 -3
View File
@@ -283,11 +283,11 @@ func (gui *Gui) configureViewProperties() {
}
}
gui.focusMainViewJumpLabel = ""
if gui.c.UserConfig().Gui.ShowPanelJumps {
gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
} else {
gui.Views.Main.TitlePrefix = ""
gui.focusMainViewJumpLabel = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
}
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
// Index the tab strips by view so we can both set them on views that are
// part of a multi-tab panel and clear them on views that no longer are
+4
View File
@@ -736,6 +736,8 @@ type TranslationSet struct {
CopyPathToClipboard string
CommitPrefixPatternError string
CopySelectedTextToClipboard string
SelectedDiffLinesCopiedToast string
SelectionNotFoundInDiffToast string
NoFilesStagedTitle string
NoFilesStagedPrompt string
BranchNotFoundTitle string
@@ -1925,6 +1927,8 @@ func EnglishTranslationSet() *TranslationSet {
CopyTagToClipboard: "Copy tag to clipboard",
CopyPathToClipboard: "Copy path to clipboard",
CopySelectedTextToClipboard: "Copy selected text to clipboard",
SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard",
SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff",
CommitPrefixPatternError: "Error in commitPrefix pattern",
NoFilesStagedTitle: "No files staged",
NoFilesStagedPrompt: "You have not staged any files. Commit all files?",
+26
View File
@@ -88,6 +88,17 @@ func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver {
return self
}
// TitlePrefix asserts on the label a view wears in front of its title, which is the
// key that jumps to it.
func (self *ViewDriver) TitlePrefix(expected *TextMatcher) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().TitlePrefix
return expected.context(fmt.Sprintf("%s title prefix", self.context)).test(actual)
})
return self
}
func (self *ViewDriver) Clear() *ViewDriver {
// clearing multiple times in case there's multiple lines
// (the clear button only clears a single line at a time)
@@ -300,6 +311,21 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver {
return self
}
// SelectedViewLineRange asserts which view lines the selection covers. View lines
// count the wrapped segments a line is drawn as, so this can say whether a selection
// covers a wrapped line to its end; SelectedLines, which reports the lines of the
// content, cannot.
func (self *ViewDriver) SelectedViewLineRange(first int, last int) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actualFirst, actualLast := self.getSelectedRange()
return actualFirst == first && actualLast == last,
fmt.Sprintf("%s: Expected view lines %d-%d to be selected, but %d-%d were.",
self.context, first, last, actualFirst, actualLast)
})
return self
}
func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) {
if len(matchers) < 1 {
self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()")
@@ -54,6 +54,6 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{
Contains("M file").IsSelected(),
)
t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
t.Views().Secondary().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
},
})
@@ -73,6 +73,7 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
SelectedLine(Contains("file1"))
t.Views().Main().Content(Contains("+second line"))
// The patch was applied to the index, so the file's changes are all staged.
t.Views().Secondary().Content(Contains("+second line"))
},
})
@@ -32,7 +32,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" R dir/nested/file3 → file3"),
)
t.Views().Main().ContainsLines(
t.Views().Secondary().ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
@@ -51,7 +51,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
SelectNextItem().
SelectedLine(Equals(" ▼ dir"))
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
@@ -75,7 +75,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" R file1 → file1"),
)
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
@@ -0,0 +1,61 @@
package file
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var PaneShownAgainStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A pane that was emptied while it wasn't shown starts at the top when it comes back, rather than where it was left",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("file2", "one\n")
shell.Commit("one")
// More staged changes in file1 than fit in the pane they are shown in, so that
// there is a position in it to lose, plus an unstaged change to give the file a
// second pane.
for i := range lines {
lines[i] = strings.ToUpper(lines[i])
}
shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.UpdateFile("file1", strings.Join(lines, "\n")+"unstaged\n")
shell.UpdateFile("file2", "two\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
NavigateToLine(Contains("file1"))
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
// A file with nothing staged leaves that pane with nothing to show, so it goes
// away and is emptied.
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Secondary().IsInvisible()
t.Views().Files().NavigateToLine(Contains("file1"))
t.Views().Secondary().
IsVisible().
Content(Contains("+LINE40")).
OriginY(0)
},
})
@@ -0,0 +1,85 @@
package file
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// changedLines is a file's worth of numbered lines, prefixed so that each file's diff
// can be told from the other's on screen.
func changedLines(prefix string) string {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("%s%02d", prefix, i+1)
}
return strings.Join(lines, "\n") + "\n"
}
var PaneTakingOverStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A pane taking the main section over shows its diff from the top, rather than at the offset it was left at",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", changedLines("one"))
shell.CreateFileAndAdd("file2", changedLines("two"))
shell.Commit("one")
// One file's changes are unstaged and the other's are staged, so each is shown
// in a pane of its own — and selecting one after the other hands the section
// from one pane to the other. Both diffs are longer than the section, so either
// pane can be scrolled.
shell.UpdateFile("file1", changedLines("ONE"))
shell.UpdateFileAndAdd("file2", changedLines("TWO"))
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
NavigateToLine(Contains("file1"))
t.Views().Secondary().IsInvisible()
t.Views().Main().
IsVisible().
Title(Equals("Unstaged changes")).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Main().IsInvisible()
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
Content(Contains("+TWO40")).
OriginY(0).
ScrollWheelDown().
ScrollWheelDown().
OriginYAtLeast(1)
// Back to the pane that was left scrolled: what it is given is a diff the user
// hasn't seen there, so it starts at the top like any other.
t.Views().Files().NavigateToLine(Contains("file1"))
t.Views().Secondary().IsInvisible()
t.Views().Main().
IsVisible().
Title(Equals("Unstaged changes")).
Content(Contains("+ONE40")).
OriginY(0)
t.Views().Files().NavigateToLine(Contains("file2"))
t.Views().Main().IsInvisible()
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
Content(Contains("+TWO40")).
OriginY(0)
},
})
@@ -34,7 +34,7 @@ var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Universal.FocusMainView).
Tap(func() {
t.Views().Main().
t.Views().Secondary().
Press(keys.Universal.IncreaseRenameSimilarityThreshold)
t.ExpectToast(Equals("Changed rename similarity threshold to 50%"))
}).
@@ -0,0 +1,81 @@
package file
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StagedChangesInLowerPane = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A file's staged changes are shown in the lower pane whether or not it also has unstaged ones",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("both", "one\n")
shell.CreateFileAndAdd("indexOnly", "one\n")
shell.CreateFileAndAdd("worktreeOnly", "one\n")
shell.Commit("one")
shell.UpdateFileAndAdd("both", "one\nstaged\n")
shell.UpdateFile("both", "one\nstaged\nunstaged\n")
// More staged lines than fit in the pane, so that it can be scrolled.
staged := make([]string, 40)
for i := range staged {
staged[i] = fmt.Sprintf("staged%02d", i+1)
}
shell.UpdateFileAndAdd("indexOnly", "one\n"+strings.Join(staged, "\n")+"\n")
shell.UpdateFile("worktreeOnly", "one\nunstaged\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
NavigateToLine(Contains("both"))
// With changes on both sides, each side has its own pane.
t.Views().Main().
Title(Equals("Unstaged changes")).
Content(Contains("+unstaged"))
t.Views().Secondary().
Title(Equals("Staged changes")).
Content(Contains("+staged"))
// With nothing unstaged, the staged side keeps its pane, which then has the
// whole space to itself.
t.Views().Files().NavigateToLine(Contains("indexOnly"))
t.Views().Main().IsInvisible()
t.Views().Secondary().
IsVisible().
Title(Equals("Staged changes")).
Content(Contains("+staged01")).
// The key that focuses the diff wears its label, wherever the diff is.
TitlePrefix(Equals("[0]")).
OriginY(0)
// And the keys for scrolling the diff scroll the pane it is in.
t.GlobalPress(keys.Universal.ScrollDownMain)
t.Views().Secondary().OriginYAtLeast(1)
t.GlobalPress(keys.Universal.ScrollUpMain)
t.Views().Secondary().OriginY(0)
// And focusing the diff focuses the pane it is in.
t.Views().Files().Press(keys.Universal.FocusMainView)
t.Views().Secondary().IsFocused()
t.Views().Secondary().PressEscape()
// With nothing staged, only the upper pane is shown.
t.Views().Files().
IsFocused().
NavigateToLine(Contains("worktreeOnly"))
t.Views().Secondary().IsInvisible()
t.Views().Main().
IsVisible().
Title(Equals("Unstaged changes")).
Content(Contains("+unstaged")).
TitlePrefix(Equals("[0]"))
},
})
@@ -0,0 +1,56 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var CopyRowsThatAreNoDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copying rows that stand for no line of the diff says so rather than copying nothing",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
// A renderer that states which line of the file each row of its diff shows, and
// ends with a row of its own that shows none. It ignores its input and prints
// this one.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `printf '\033]1717;1\007'; ` +
`printf '\033]1717;1;f;;;file1\007file1\n'; ` +
`printf '\033]1717;1;c;1;;file1\007 one\n'; ` +
`printf '\033]1717;1;d;2;2;file1\007-two\n'; ` +
`printf '\033]1717;1;a;2;;file1\007+TWO\n'; ` +
`printf '\033]1717;1;c;3;;file1\007 three\n'; ` +
`printf -- '--- that was the diff ---\n'; ` +
`cat >/dev/null`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-two"),
).
SelectNextItem().
SelectNextItem().
SelectNextItem().
SelectedLines(
Contains("that was the diff"),
).
Press(keys.Universal.CopyToClipboard)
t.ExpectToast(Equals("Nothing in the selection could be found in the diff"))
t.FileSystem().PathNotPresent("clipboard")
},
})
@@ -0,0 +1,141 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// The clipboard is emulated by a file, so that this works on CI too. A copy says so
// with a toast, which the harness wants acknowledged before the next keypress.
func expectClipboard(t *TestDriver, matcher *TextMatcher) {
defer t.Shell().DeleteFile("clipboard")
t.ExpectToast(Equals("Selected diff lines copied to clipboard"))
t.FileSystem().FileContent("clipboard", matcher)
}
var CopySelectedDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Copy the selected diff lines from the focused main view, as the diff reads rather than as the renderer drew it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
// Emulate the clipboard by writing to a file.
cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard"
// A renderer that decorates every line of a diff's body, so that what is on
// screen is not what the diff says. It reads the +/- column, so it wants its
// input uncoloured.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{
Command: `awk '/^@@/ { body = 1 } body && /^[-+ ]/ { print $0 " <<<"; next } { print }'`,
ColorArg: "never",
},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("two")
shell.UpdateFile("file1", "one\nTWO\nADD1\nADD2\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+ADD1 <<<"),
Contains("+ADD2 <<<"),
).
Press(keys.Universal.CopyToClipboard)
// The renderer's decoration is nowhere in what was copied, and a selection that
// is all additions loses its '+' column, ready to be pasted into code.
expectClipboard(t, Equals("ADD1\nADD2\n"))
// A selection reaching up into the hunk header takes the header with it, and what
// comes out is a patch fragment rather than lines of code, so the columns stay.
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("+ADD1 <<<"),
).
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.PrevItem).
Press(keys.Universal.PrevItem).
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("@@ -1,3 +1,5 @@"),
Contains(" one <<<"),
Contains(" TWO <<<"),
Contains("+ADD1 <<<"),
).
Press(keys.Universal.CopyToClipboard)
expectClipboard(t, Equals("@@ -1,3 +1,5 @@\n one\n TWO\n+ADD1\n"))
// A file header is taken whole, however much of it is selected: the rendering may
// show it as any number of rows, none of which is a line of the file.
t.Views().Main().
IsFocused().
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("+++ b/file1"),
Contains("@@ -1,3 +1,5 @@"),
Contains(" one <<<"),
Contains(" TWO <<<"),
Contains("+ADD1 <<<"),
).
Press(keys.Universal.CopyToClipboard)
expectClipboard(t, MatchesRegexp(
`\Adiff --git a/file1 b/file1\nindex [0-9a-f]+\.\.[0-9a-f]+ 100644\n`+
`--- a/file1\n\+\+\+ b/file1\n@@ -1,3 \+1,5 @@\n one\n TWO\n\+ADD1\n\z`))
// A commit's diff is copied the same way, through the panel that produced it.
t.Views().Commits().
Focus().
NavigateToLine(Contains("two")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-two <<<"),
Contains("+TWO <<<"),
).
Press(keys.Universal.CopyToClipboard)
// Both kinds of line are in the selection, so the columns stay: what comes out
// is the diff itself.
expectClipboard(t, Equals("-two\n+TWO\n"))
// What a diff view shows above the diff is part of no file's diff, a commit's
// message among it, so a selection of those rows is copied as it stands on screen.
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
NavigateToLine(Contains(" two")).
Press(keys.Universal.CopyToClipboard)
expectClipboard(t, Equals(" two\n"))
// A selection reaching from there into the diff takes the screen's rows down to
// the first file and the diff's own from there on.
t.Views().Main().
IsFocused().
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("-two <<<")).
Press(keys.Universal.CopyToClipboard)
expectClipboard(t, MatchesRegexp(
`\A {4}two\n---\n file1 \|[^\n]*\n 1 file changed[^\n]*\n\n`+
`diff --git a/file1 b/file1\nindex [0-9a-f]+\.\.[0-9a-f]+ 100644\n`+
`--- a/file1\n\+\+\+ b/file1\n@@ -1,3 \+1,3 @@\n one\n-two\n\z`))
},
})
@@ -0,0 +1,60 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepAWrappedLineCoveredAcrossARerender = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A selection over a line too long for the view still covers all of it after a re-render",
ExtraCmdArgs: []string{},
Skip: false,
Width: 80,
Height: 20,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
long := strings.Repeat("word ", 40)
lines := make([]string, 20)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
before := strings.Join(lines[:10], "\n") + "\n"
after := strings.Join(lines[10:], "\n") + "\n"
shell.CreateFileAndAdd("file1", before+long+"\n"+after)
shell.Commit("one")
shell.UpdateFile("file1", before+"CHANGED "+long+"\n"+after)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The changed line is far too long for the view, so each half of the change
// is drawn as several view lines, and hunk mode selects all of them.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-word word"),
Contains("+CHANGED word"),
).
SelectedViewLineRange(8, 16).
// The same two lines of the diff, wrapped the same way, are still covered
// to their ends once the diff has been rendered again.
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
SelectedLines(
Contains("-word word"),
Contains("+CHANGED word"),
).
SelectedViewLineRange(9, 17)
},
})
@@ -0,0 +1,68 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepBothHalvesOfAChangeSelected = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A change selected on the one row a renderer draws it as is selected on both rows of a renderer that splits it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
// Git's own diff, which has a row for each half of a change. It announces
// the metadata protocol, so lazygit acts on its output rather than
// replacing it; it states no records of its own, so the rows are located
// by parsing the text, which still looks like a diff.
{Name: "unified", Command: `printf '\033]1717;1\007'; cat`},
// A renderer that puts the two halves of a change beside each other on one
// row. Only the records it states can say where those halves are; parsing
// the text could not. It ignores its input and prints this one.
{Name: "columns", Command: `printf '\033]1717;1\007'; ` +
`printf '\033]1717;1;f;;;file1\007file1\n'; ` +
`printf '\033]1717;1;h;1;;file1\007@@\n'; ` +
`printf '\033]1717;1;c;1;;file1\007one one\n'; ` +
`printf '\033]1717;1;d;2;2;file1\007two \033]1717;1;a;2;;file1\007TWO\n'; ` +
`printf '\033]1717;1;c;3;;file1\007three three\n'; ` +
`cat >/dev/null`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-two"),
Contains("+TWO"),
).
// The change is one row here, and selecting it selects that row: both
// halves are on it.
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: columns (2 of 2)"))
}).
SelectedLines(
Contains("two TWO"),
).
// Split apart again, the same change is the same two lines it was.
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: unified (1 of 2)"))
}).
SelectedLines(
Contains("-two"),
Contains("+TWO"),
)
},
})
@@ -0,0 +1,102 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionByTheVisibleEndOfASelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A re-render keeps the place by the end of a selected hunk that is on screen when its other end isn't",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
// One line per scroll, so that the test can put the top of the view exactly
// where it wants it.
cfg.GetUserConfig().Gui.ScrollHeight = 1
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// A first change tall enough to be scrolled halfway out of the view, and more
// of them below it, so that a context-size change moves the lines further down
// the diff by more than it moves the first change.
for _, i := range []int{10, 11, 12, 13, 14, 15, 30, 45} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-line10"),
Contains("-line11"),
Contains("-line12"),
Contains("-line13"),
Contains("-line14"),
Contains("-line15"),
Contains("+LINE10"),
Contains("+LINE11"),
Contains("+LINE12"),
Contains("+LINE13"),
Contains("+LINE14"),
Contains("+LINE15"),
).
SelectedLineIdx(8).
// Scroll past the start of the selected block, leaving its last lines on
// screen and the cursor above the top of the view.
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
OriginY(14).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
// The block's last line was the fifth row of the screen, and one context
// line more above the block puts it a line further down the diff: the view
// follows it, rather than the middle visible line, which the hunks below
// have pushed further still.
OriginY(15).
SelectedLines(
Contains("-line10"),
Contains("-line11"),
Contains("-line12"),
Contains("-line13"),
Contains("-line14"),
Contains("-line15"),
Contains("+LINE10"),
Contains("+LINE11"),
Contains("+LINE12"),
Contains("+LINE13"),
Contains("+LINE14"),
Contains("+LINE15"),
)
},
})
@@ -0,0 +1,71 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionInBothPanesWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Changing the diff's context size keeps the place in the lower pane too, not only in the upper one",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Four staged changes, far enough apart that they stay four hunks as the
// context size grows, and one unstaged one to split the file's diff across
// both panes.
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
shell.GitAddAll()
lines[39] = strings.ToUpper(lines[39])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The lower pane holds the staged changes; getting to the last of them scrolls
// it, so there is a position to lose.
t.Views().Main().
IsFocused().
PressTab()
t.Views().Secondary().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(42).
OriginY(21)
},
})
@@ -0,0 +1,70 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionInBothPanesWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace keeps the place in the lower pane too, not only in the upper one",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Staged: real changes at lines 5, 25 and 45, and a whitespace-only one at 15,
// whose hunk goes when whitespace stops counting.
lines[4] = strings.ToUpper(lines[4])
lines[14] = " " + lines[14]
lines[24] = strings.ToUpper(lines[24])
lines[44] = strings.ToUpper(lines[44])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
shell.GitAddAll()
// And one unstaged change, to split the file's diff across both panes.
lines[59] = strings.ToUpper(lines[59])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
PressTab()
t.Views().Secondary().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line45"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// The hunk above this one held nothing but a whitespace change, so it is
// gone and has taken nine lines of the lower pane's diff with it — leaving
// the line we were on where it was on the screen.
SelectedLines(
Contains("-line45"),
).
SelectedLineIdx(26).
OriginY(5)
},
})
@@ -0,0 +1,78 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionInBothPanesWhenSwitchingDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Switching to another diff renderer keeps the place in the lower pane too, not only in the upper one",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// Renderers that speak the metadata protocol, so that focusing the main view
// keeps their rendering rather than falling back to git's own diff.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Name: "plain", Command: `printf '\033]1717;1\007'; cat`},
// The same diff, three lines further down the view. (Lines before the
// diff's own header aren't part of it, so it still reads the same.)
{Name: "banner", Command: `printf '\033]1717;1\007'; printf 'rendered for you\n\n\n'; cat`},
}
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Four staged changes to have a diff worth scrolling in the lower pane, and one
// unstaged one to split the file's diff across both panes.
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
shell.GitAddAll()
lines[39] = strings.ToUpper(lines[39])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
PressTab()
t.Views().Secondary().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: banner (2 of 2)"))
}).
// The banner pushed the whole diff three lines down, and the lower pane came
// along with it, just as the upper one would have.
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(38).
OriginY(17)
},
})
@@ -0,0 +1,97 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Changing the diff's context size keeps the line you were looking at where it was",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Four changes, far enough apart that they stay four hunks as the context
// size grows.
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
// The diff is longer than the view, so getting to the last hunk scrolled
// it: the selected line sits 21 rows down the screen.
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
// A context line more on either side of each of the four hunks pushes the
// selected line seven lines further into the diff. The view follows it, so
// it is still the same line on the same screen row (42 - 21 = 21).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(42).
OriginY(21).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 3"))
}).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
// And the same the other way (28 - 21 = 7).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(28).
OriginY(7).
// Leaving the view gives up the selection but not the scroll position, and
// with no selection to keep, it is the middle visible line that stays put.
PressEscape()
t.Views().Files().
IsFocused().
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 3"))
})
// The middle visible line here is a hunk's header, and a context-size change
// rewrites those — they name the lines the hunk covers. So the restore falls
// back to the nearest line that does survive, the context line just below it,
// and puts that back on the row it was on.
t.Views().Main().
SelectionIsHidden().
OriginY(12)
},
})
@@ -0,0 +1,85 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenIgnoringWhitespace = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace keeps the line you were looking at where it was, even when it turns into a context line",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
// Real changes at lines 5 and 25, whitespace-only ones at 15, 27 and 35. The
// one at 27 shares a hunk with the change at 25, so ignoring whitespace turns
// it into a context line rather than taking its hunk away.
lines[4] = strings.ToUpper(lines[4])
lines[14] = " " + lines[14]
lines[24] = strings.ToUpper(lines[24])
lines[26] = lines[26] + " "
lines[34] = " " + lines[34]
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(26).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// The hunk above this one held nothing but a whitespace change, so it is
// gone and has taken nine lines of diff with it. This is still the line we
// were on, on the row we were on (26 - 14 = 17 - 5).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(17).
OriginY(5).
// And back again, whitespace and all.
Press(keys.Universal.ToggleWhitespaceInDiffView).
SelectedLines(
Contains("-line25"),
).
SelectedLineIdx(26).
OriginY(14).
// The whitespace-only change further down this hunk is a line of the file
// like any other: ignoring whitespace shows it as context instead of as a
// change, and that is still where we are.
Press(keys.Main.NextHunk).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+line27"),
).
SelectedLineIdx(30).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
SelectedLines(
Contains(" line27"),
).
SelectedLineIdx(20).
OriginY(4)
},
})
@@ -0,0 +1,81 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenIgnoringWhitespaceRemovesIt = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ignoring whitespace where that takes the line you were on out of the diff lands on the nearest line it kept",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
shell.Commit("one")
// Real changes at lines 5, 15 and 25, and a whitespace-only one at 35, far
// enough apart to be hunks of their own.
for _, i := range []int{5, 15, 25} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
lines[34] = " " + lines[34]
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
// Nothing but reindentation, so ignoring whitespace leaves no diff at all.
shell.UpdateFile("file2", " one\n two\n three\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
SelectNextItem().
SelectedLine(Contains("file1")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.ToggleWhitespaceInDiffView).
// That hunk was a whitespace change and nothing else, so ignoring
// whitespace takes it — and the context around it — out of the diff
// entirely. The nearest line the diff kept is the last line of the hunk
// above, so that is where the selection lands; it goes back on the row it
// was on itself, which leaves everything above it exactly where it was.
SelectedLines(
Contains(" line28"),
).
SelectedLineIdx(30).
OriginY(14).
// The whole diff can go this way, and then there is nothing to land on.
Press(keys.Universal.ToggleWhitespaceInDiffView).
PressEscape()
t.Views().Files().
IsFocused().
SelectNextItem().
SelectedLine(Contains("file2")).
Press(keys.Universal.ToggleWhitespaceInDiffView)
t.Views().Main().
Content(Equals(""))
},
})
@@ -0,0 +1,75 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenSwitchingDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Switching to another diff renderer keeps the line you were looking at where it was",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Name: "plain", Command: "cat"},
// The same diff, three lines further down the view. (Lines before the
// diff's own header aren't part of it, so it still reads the same.)
{Name: "banner", Command: `printf 'rendered for you\n\n\n'; cat`},
}
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14).
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: banner (2 of 2)"))
}).
// The banner pushed the whole diff three lines down, and the view came
// along with it: the same line on the same screen row (38 - 17 = 21).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(38).
OriginY(17).
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: plain (1 of 2)"))
}).
SelectedLines(
Contains("-line35"),
).
SelectedLineIdx(35).
OriginY(14)
},
})
@@ -0,0 +1,65 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepPositionWhenTheSelectionIsOffScreen = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A re-render keeps the lines that are on screen where they are, not a selection scrolled away from",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// Half a diff per scroll, to leave the selection well behind in two presses.
cfg.GetUserConfig().Gui.ScrollHeight = 15
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{5, 15, 25, 35, 45, 55} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-line05"),
).
// Reading on past the selection leaves it far behind, off the top of the
// view.
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
OriginY(30).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
// A context line less on either side of the four hunks above what is on
// screen pulls it nine lines up the diff, and the view follows it there: the
// lines the user was reading are still on the rows they were on.
OriginY(21).
// The selection is where it always was, on its own line of the diff, rather
// than having been dragged back into view.
SelectedLines(
Contains("-line05"),
).
SelectedLineIdx(7)
},
})
@@ -0,0 +1,58 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepScrollWhenTheDiffCantBeRead = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Changing the context size under a diff renderer whose rows can't be placed keeps the scroll position rather than jumping to the top",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// A renderer that says nothing about which line of which file each row shows,
// and mangles the diff enough that it can't be read back as one either: no line
// of it can be looked for in the re-render.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Name: "opaque", Command: `sed -e 's/^/| /'`},
}
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain)
t.Views().Main().
Content(Contains("| +LINE05")).
OriginY(6).
Tap(func() {
t.Views().Files().Press(keys.Universal.IncreaseContextInDiffView)
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
// The re-render is a different command, and nothing in its output can be
// matched up with what was on screen, so the offset is all there is to keep —
// and it is a good deal closer than the top.
OriginY(6)
},
})
@@ -0,0 +1,123 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepSelectedRangeWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A range selection still covers the same lines of the diff after the context size changes",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 40)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{5, 15, 25, 35} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// A range from a change down into the context below it, so that the cursor is
// on the last line of the selection and the other end is three lines above.
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
Press(keys.Main.NextHunk).
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.NextItem).
Press(keys.Universal.NextItem).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-line25"),
Contains("+LINE25"),
Contains(" line26"),
Contains(" line27"),
).
// Both ends are still lines of the diff with more context around the
// change, so the selection still covers the same four.
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
SelectedLines(
Contains("-line25"),
Contains("+LINE25"),
Contains(" line26"),
Contains(" line27"),
).
// With a single line of context, the line the cursor was on is no longer in
// the diff. The end that survived stays put and the cursor lands on the
// nearest line that is left, so the selection shrinks with the diff.
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 3"))
}).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 1"))
}).
SelectedLines(
Contains("-line25"),
Contains("+LINE25"),
Contains(" line26"),
).
// The other way round: a range extended upwards, so that it is the far end
// that the shrinking context takes away. There is no guessing which line
// inherits it, so what is left is the line the cursor is on.
PressEscape().
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 3"))
}).
SelectedLines(
Contains(" line26"),
).
Press(keys.Universal.NextItem).
Press(keys.Universal.RangeSelectUp).
Press(keys.Universal.RangeSelectUp).
Press(keys.Universal.RangeSelectUp).
SelectedLines(
Contains("-line25"),
Contains("+LINE25"),
Contains(" line26"),
Contains(" line27"),
).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 2"))
}).
Press(keys.Universal.DecreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 1"))
}).
SelectedLines(
Contains("-line25"),
)
},
})
@@ -26,7 +26,7 @@ var SelectLineWhenWholeFileIsOneHunk = NewIntegrationTest(NewIntegrationTestArgs
// Every line of the file is an addition, so widening to the change block would
// select the file entire; hunk mode gives way to a single line.
t.Views().Main().
t.Views().Secondary().
IsFocused().
SelectedLines(
Contains("+one"),
@@ -58,7 +58,7 @@ var Apply = NewIntegrationTest(NewIntegrationTestArgs{
Contains("file1").IsSelected(),
)
t.Views().Main().
t.Views().Secondary().
Content(Contains("second line"))
},
})
@@ -45,7 +45,7 @@ var ApplyInReverse = NewIntegrationTest(NewIntegrationTestArgs{
Contains("D").Contains("file1").IsSelected(),
)
t.Views().Main().
t.Views().Secondary().
Content(Contains("-file1 content"))
},
})
@@ -88,7 +88,7 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" M file2"),
)
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Contains(" file1 content"),
Contains("-more file1 content"),
@@ -63,7 +63,7 @@ var ApplyWithModifiedFileNoConflict = NewIntegrationTest(NewIntegrationTestArgs{
Equals("M file1").IsSelected(),
)
t.Views().Main().
t.Views().Secondary().
Content(Contains("-1\n+11\n 2\n 3\n+4"))
},
})
@@ -62,12 +62,12 @@ var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{
Equals(" A file2"),
)
t.Views().Main().
t.Views().Secondary().
Content(Contains("second line"))
t.Views().Files().Focus().NavigateToLine(Contains("file2"))
t.Views().Main().
t.Views().Secondary().
Content(Contains("file two content"))
},
})
@@ -62,7 +62,7 @@ var MoveToIndex = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
Focus()
t.Views().Main().
t.Views().Secondary().
Content(Contains("file1 content"))
},
})
@@ -63,7 +63,7 @@ var MoveToIndexPartOfAdjacentAddedLines = NewIntegrationTest(NewIntegrationTestA
Contains("M").Contains("file1"),
)
t.Views().Main().
t.Views().Secondary().
Content(Contains("+1st line\n 2nd line"))
},
})
@@ -85,7 +85,7 @@ var MoveToIndexPartial = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
Focus()
t.Views().Main().
t.Views().Secondary().
ContainsLines(
Contains(`-first line`),
Contains(`+first line2`),
+2 -2
View File
@@ -50,7 +50,7 @@ var Add = NewIntegrationTest(NewIntegrationTestArgs{
).
SelectNextItem().
Tap(func() {
t.Views().Main().Content(
t.Views().Secondary().Content(
Contains("[submodule \"my_submodule\"]").
Contains("path = my_submodule_path").
Contains("url = ../other_repo"),
@@ -58,7 +58,7 @@ var Add = NewIntegrationTest(NewIntegrationTestArgs{
}).
SelectNextItem().
Tap(func() {
t.Views().Main().Content(
t.Views().Secondary().Content(
Contains("Submodule my_submodule_path").
Contains("(new submodule)"),
)
+1 -1
View File
@@ -41,7 +41,7 @@ var Remove = NewIntegrationTest(NewIntegrationTestArgs{
).
SelectNextItem()
t.Views().Main().Content(
t.Views().Secondary().Content(
Contains("-[submodule \"my_submodule_name\"]").
Contains("- path = my_submodule_path").
Contains("- url = ../my_submodule_name"),
@@ -46,7 +46,7 @@ var RemoveNested = NewIntegrationTest(NewIntegrationTestArgs{
).
NavigateToLine(Contains(".gitmodules"))
t.Views().Main().Content(
t.Views().Secondary().Content(
Contains("-[submodule \"innerSubName\"]").
Contains("- path = modules/innerSubPath").
Contains("- url = ../innerSubmodule"),
+18
View File
@@ -253,6 +253,8 @@ var tests = []*components.IntegrationTest{
file.ExcludeWithoutInfoDir,
file.Gitignore,
file.GitignoreSpecialCharacters,
file.PaneShownAgainStartsAtTheTop,
file.PaneTakingOverStartsAtTheTop,
file.RememberCommitMessageAfterFail,
file.RenameSimilarityThresholdChange,
file.RenamedFiles,
@@ -261,6 +263,7 @@ var tests = []*components.IntegrationTest{
file.StageChildrenRangeSelect,
file.StageDeletedRangeSelect,
file.StageRangeSelect,
file.StagedChangesInLowerPane,
filter_and_search.FilterByFileStatus,
filter_and_search.FilterCommitFiles,
filter_and_search.FilterCommitFilesToggleDirectory,
@@ -365,12 +368,27 @@ var tests = []*components.IntegrationTest{
interactive_rebase.SwapWithConflict,
interactive_rebase.ViewFilesOfTodoEntries,
main_view.ClickSelectsDiffLine,
main_view.CopyRowsThatAreNoDiffLine,
main_view.CopySelectedDiffLines,
main_view.DragRangeWithAutoscroll,
main_view.DragSelectsDiffLineRange,
main_view.EditSelectedDiffLine,
main_view.EscapeDismissesSelection,
main_view.FileNavigationScrollsToTheTop,
main_view.HideSelectionWhenChangesVanish,
main_view.KeepAWrappedLineCoveredAcrossARerender,
main_view.KeepBothHalvesOfAChangeSelected,
main_view.KeepPositionByTheVisibleEndOfASelection,
main_view.KeepPositionInBothPanesWhenChangingContextSize,
main_view.KeepPositionInBothPanesWhenIgnoringWhitespace,
main_view.KeepPositionInBothPanesWhenSwitchingDiffRenderers,
main_view.KeepPositionWhenChangingContextSize,
main_view.KeepPositionWhenIgnoringWhitespace,
main_view.KeepPositionWhenIgnoringWhitespaceRemovesIt,
main_view.KeepPositionWhenSwitchingDiffRenderers,
main_view.KeepPositionWhenTheSelectionIsOffScreen,
main_view.KeepScrollWhenTheDiffCantBeRead,
main_view.KeepSelectedRangeWhenChangingContextSize,
main_view.KeepSelectionVisibleWhenDiffShrinks,
main_view.NavigateByHunkAndFile,
main_view.NoSelectionOverABinaryDiff,
+158 -9
View File
@@ -98,6 +98,24 @@ type ViewBufferManager struct {
// what that task was owed.
newContentPending atomic.Bool
// When set, the next command task puts the view back where it was once it has
// re-rendered the content, instead of showing the new render from the top (see
// RenderRestore). It is installed just before the re-render is triggered.
//
// Like newContentPending it outlives the task it was installed for, and for the
// same reason: that task can be stopped and replaced before it ever paints, and
// the replacement, rendering the same content, is then the one that owes the
// user their position. It is cleared by whichever task applies it. Guarded by
// taskIDMutex, like the task key.
restoreForNextTask *RenderRestore
// When set, the next command task leaves the view's scroll position alone even
// though it renders a different command's output, that output being the same
// content laid out differently (see SetKeepScrollPositionForNextTask). The task
// that starts consumes it, in place of noting that new content is on its way.
// Guarded by taskIDMutex, like the task key.
keepScrollForNextTask bool
// Whether a command task is currently reading content into the view. While
// this is true the content is still growing, so callers (e.g. the layout)
// must not clamp the view's scroll position to the amount loaded so far.
@@ -152,6 +170,80 @@ type LinesToRead struct {
Then func()
}
// RenderRestore puts a view back where it was when it re-renders content the user
// is already looking at, laid out differently — a different context size, whitespace
// ignored, another diff renderer — instead of showing the new render from the top.
//
// The task reads the new content into an off-screen buffer; the restore says when
// enough of it has arrived to show the remembered position (FirstPaintReady), and
// then finds that position and reveals it (Apply). It is a pair of callbacks rather
// than a scroll position because a different layout of the same content puts the
// remembered line somewhere else, and only the new content itself says where.
type RenderRestore struct {
// FirstPaintReady reports whether enough of the new content has been read for
// the restore to show what it is looking for. It is consulted after each line
// is read, on the task's own goroutine.
FirstPaintReady func() bool
// Apply runs once, on the UI thread, at the first paint. It finds its target in
// the off-screen content, calls swapIn to promote that content to the display,
// and places the view on the target — in that order, so that the search runs
// while the previous content is still displayed, and the new content is never
// drawn at the previous render's scroll position.
//
// It must call swapIn either way, and reports whether it placed the view: when
// it didn't, because what it was looking for is not in the new content, the
// task does what it would have done without a restore.
Apply func(swapIn func()) bool
}
// SetRestoreForNextTask arranges for the next command task to put the view back
// where it is now once it has re-rendered. Call it right before triggering a
// re-render of the content the view is showing; see RenderRestore.
func (self *ViewBufferManager) SetRestoreForNextTask(restore *RenderRestore) {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
self.restoreForNextTask = restore
}
func (self *ViewBufferManager) getRestoreForNextTask() *RenderRestore {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
return self.restoreForNextTask
}
// SetKeepScrollPositionForNextTask arranges for the next command task to leave the
// view's scroll position alone, rather than showing its content from the top the way a
// render of different content does. Call it right before triggering a re-render of the
// content the view is showing, when the command producing it is not the one that
// produced what is on screen — a different context size, another diff renderer.
//
// It is the coarser sibling of SetRestoreForNextTask, for the same moment. The restore
// puts the view back on the line it remembers, which it can only do when the lines of
// the new rendering can be told apart. This one says merely "the content is a
// rearrangement of what is there, so the offset into it is nearer to where the user was
// than the top is". Both can be set at once, and then the restore has the first say.
func (self *ViewBufferManager) SetKeepScrollPositionForNextTask() {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
self.keepScrollForNextTask = true
}
// clearRestore drops a restore once a task has applied it, so that it rides exactly
// one re-render. One installed since — the user pressing the key again while this
// task was still reading — is left alone: it belongs to the render on its way.
func (self *ViewBufferManager) clearRestore(restore *RenderRestore) {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
if self.restoreForNextTask == restore {
self.restoreForNextTask = nil
}
}
func (self *ViewBufferManager) GetTaskKey() string {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
@@ -159,6 +251,17 @@ func (self *ViewBufferManager) GetTaskKey() string {
return self.taskKey
}
// ForgetRenderedContent records that the view no longer shows the render whose key it
// is holding, because it has been emptied. The key says what the view is showing, and
// the next task is compared against it to tell whether that task renders something
// new. A view with nothing in it is showing nothing, so whatever comes next is new.
func (self *ViewBufferManager) ForgetRenderedContent() {
self.taskIDMutex.Lock()
defer self.taskIDMutex.Unlock()
self.taskKey = ""
}
func NewViewBufferManager(
log *logrus.Entry,
writer io.Writer,
@@ -272,6 +375,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
onFirstPageShown()
}
// Whatever position is owed to the user belongs to this render: it was
// remembered just before the re-render that led here was triggered.
restore := self.getRestoreForNextTask()
if self.throttle.Load() {
self.Log.Info("throttling task")
time.Sleep(THROTTLE_TIME)
@@ -370,7 +477,12 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
// content is common (a background refresh over a repo with submodules
// that have uncommitted changes, say). The pending flag isn't consumed
// here; the first paint still owes the scroll reset.
if !loaded && self.newContentPending.Load() {
//
// A restore keeps the view too: it is there to make a re-render of what
// the user is looking at seamless, and blanking the view for a message
// before putting them back where they were is the flicker it exists to
// avoid.
if !loaded && restore == nil && self.newContentPending.Load() {
self.beforeStart()
// beforeStart cleared the previous content to show "loading...", so
// put the view back at the top for it (beforeStart doesn't touch the
@@ -431,6 +543,18 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
return
}
painted = true
if restore != nil {
// The restore does the swap itself, so that it can find where the
// user was in the new content before it is revealed.
placed := restore.Apply(self.swapInRender)
self.clearRestore(restore)
if placed {
// The view is where the user left it, which is exactly what the
// scroll reset would undo.
self.newContentPending.Store(false)
return
}
}
self.swapInRender()
if self.newContentPending.Swap(false) {
self.resetOrigin()
@@ -469,7 +593,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
linesToRead.Then()
}
}
for linesToRead.Total == -1 || linesRead < linesToRead.Total {
// A restore that hasn't painted yet keeps us reading past the lines
// asked for, all the way to the end of the input if need be. What it
// is looking for may be anywhere in the new content, and a rendering
// that has to be parsed as a diff to be searched at all can only be
// parsed whole — so stopping early would leave it nothing to find,
// and the view somewhere the user didn't put it.
for linesToRead.Total == -1 || linesRead < linesToRead.Total || (restore != nil && !painted) {
if stopped() {
callThen()
break outer
@@ -540,12 +670,22 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
time.Sleep(slowRenderPerLine)
}
if linesRead == linesToRead.InitialRefreshAfter {
// We have read enough lines to fill the view, so do the first paint
// and refresh to show it. Continue reading and refresh again at the
if !painted {
// Do the first paint once we have read enough lines to fill the
// view — or, when a position is waiting to be restored, once the
// restore says it can show it, since where the view should be is
// its call. Continue reading afterwards and refresh again at the
// end to make sure the scrollbar has the right size.
_ = self.onUIThread(firstPaint)
refreshViewIfStale()
var ready bool
if restore != nil {
ready = restore.FirstPaintReady()
} else {
ready = linesRead == linesToRead.InitialRefreshAfter
}
if ready {
_ = self.onUIThread(firstPaint)
refreshViewIfStale()
}
}
}
refreshViewIfStale()
@@ -672,10 +812,19 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error
// newContentPending), so the previous content — left displayed until the
// swap — doesn't visibly jump to the top before the new content appears.
// Read taskKey directly: we already hold the mutex that guards it, and
// GetTaskKey would take it again.
if self.taskKey != key && self.resetOrigin != nil {
// GetTaskKey would take it again. A pending restore isn't dropped here
// either, even for a different command: the re-renders it rides are all
// different commands (a different context size, another diff renderer), and
// it validates itself against the content it lands in anyway.
// A task told to keep the scroll position renders the content the view is
// already showing, laid out differently, so the reset it would otherwise owe
// would take the user away from what they are reading — and the loading
// message, which the same flag governs, would blank content that is about to
// come back looking much the same.
if self.taskKey != key && self.resetOrigin != nil && !self.keepScrollForNextTask {
self.newContentPending.Store(true)
}
self.keepScrollForNextTask = false
self.taskKey = key
self.taskIDMutex.Unlock()
+269
View File
@@ -385,6 +385,275 @@ func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) {
2*time.Second, 10*time.Millisecond)
}
// A pending restore takes the first paint over: it says when enough of the new
// content has arrived to show the position it remembers, and does the swap itself so
// that it can look for that position while the previous content is still displayed.
// Having put the view where the user left it, it also keeps the scroll reset that new
// content would otherwise get.
func TestNewCmdTaskRestore(t *testing.T) {
writer := bytes.NewBuffer(nil)
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
resetOrigin, getResetOriginCallCount := getCounter()
swapped := false
applyCount := 0
applyAtLines := -1
swappedBeforeApply := false
swappedByApply := false
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
func() {}, // beforeStart
func() {}, // refreshView
func() {}, // onEndOfInput
resetOrigin,
func() {}, // beginRender
func() { swapped = true }, // swapInRender
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
manager.SetRestoreForNextTask(&RenderRestore{
// Ready once five lines have loaded — well before the view is filled (30).
FirstPaintReady: func() bool { return linesWritten() >= 5 },
Apply: func(swapIn func()) bool {
applyCount++
applyAtLines = linesWritten()
swappedBeforeApply = swappedBeforeApply || swapped
swapIn()
swappedByApply = swapped
return true
},
})
done := make(chan struct{})
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
}
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
<-done
assert.Equal(t, 1, applyCount, "Apply should run exactly once")
assert.False(t, swappedBeforeApply, "the off-screen render should not be swapped in before Apply runs")
assert.True(t, swappedByApply, "Apply should swap the off-screen render in via swapIn")
// The first paint was driven by the restore, not by having read enough lines to
// fill the view.
assert.GreaterOrEqual(t, applyAtLines, 5)
assert.Less(t, applyAtLines, 30)
assert.Equal(t, 0, getResetOriginCallCount(), "a restore that placed the view leaves the scroll alone")
}
// A restore that never finds what it is looking for keeps the task reading to the
// end of its input, since the line might have been anywhere in it. Once there is no
// more content to hope for, the render is revealed with the scroll reset that new
// content is owed.
func TestNewCmdTaskRestoreThatFindsNothing(t *testing.T) {
writer := bytes.NewBuffer(nil)
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
resetOrigin, getResetOriginCallCount := getCounter()
applyCount := 0
swappedAtLines := -1
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
func() {}, // beforeStart
func() {}, // refreshView
func() {}, // onEndOfInput
resetOrigin,
func() {}, // beginRender
func() { swappedAtLines = linesWritten() },
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
manager.SetRestoreForNextTask(&RenderRestore{
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) bool {
applyCount++
swapIn()
return false
},
})
done := make(chan struct{})
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
}
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
<-done
assert.Equal(t, 1, applyCount, "Apply should still run, to swap the render in")
assert.Equal(t, 50, swappedAtLines, "the whole input should be read before giving up on the restore")
assert.Equal(t, 1, getResetOriginCallCount(), "new content the restore couldn't place starts at the top")
}
// The task a restore was installed for can be stopped and replaced before it ever
// paints — a background refresh landing right after the key was pressed. The
// replacement renders the same content, so it is the one that owes the user their
// position.
func TestRestoreSurvivesTaskReplacement(t *testing.T) {
var applyCount atomic.Int32
manager := NewViewBufferManager(
utils.NewDummyLog(),
io.Discard,
func() {},
func() {},
func() {},
func() {},
func() {},
func() {},
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
manager.SetRestoreForNextTask(&RenderRestore{
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) bool {
applyCount.Add(1)
swapIn()
return true
},
})
startTask := func(reader io.Reader, onDone func()) {
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, reader
}
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), "cmd")
}
// The task the restore was installed for stalls before it can paint.
stalled := BlockingLineReader{
linesToYield: 3,
blocked: make(chan struct{}),
unblock: make(chan struct{}),
}
defer close(stalled.unblock)
startTask(&stalled, nil)
<-stalled.blocked
done := make(chan struct{})
startTask(&BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
<-done
assert.EqualValues(t, 1, applyCount.Load(), "the replacement should apply the restore the stopped task couldn't")
}
// A task told to keep the scroll position renders the content the view is showing
// under another command — the same diff with more context around it, say — so it
// neither resets the scroll nor blanks the view to say "loading...", both of which are
// for content the user hasn't seen.
func TestKeepScrollPositionForNextTask(t *testing.T) {
var beforeStartCount atomic.Int32
resetOrigin, getResetOriginCallCount := getCounter()
manager := NewViewBufferManager(
utils.NewDummyLog(),
io.Discard,
func() { beforeStartCount.Add(1) },
func() {}, // refreshView
func() {}, // onEndOfInput
resetOrigin,
func() {}, // beginRender
func() {}, // swapInRender
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
startTask := func(key string, reader io.Reader, onDone func()) {
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, reader
}
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
}
runTaskToCompletion := func(key string) {
done := make(chan struct{})
startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
<-done
}
// Content the view wasn't showing, to have something to keep the position in.
runTaskToCompletion("cmd1")
assert.Equal(t, 1, getResetOriginCallCount())
manager.SetKeepScrollPositionForNextTask()
runTaskToCompletion("cmd2")
assert.Equal(t, 1, getResetOriginCallCount(), "the same content under another command keeps its position")
// And the request rides one task only: the next different command is a different
// diff as far as anyone knows.
runTaskToCompletion("cmd3")
assert.Equal(t, 2, getResetOriginCallCount())
// The loading indicator goes by the same question, so it stays out of the way too.
manager.SetKeepScrollPositionForNextTask()
stalled := BlockingLineReader{
blocked: make(chan struct{}),
unblock: make(chan struct{}),
}
defer close(stalled.unblock)
startTask("cmd4", &stalled, nil)
<-stalled.blocked
time.Sleep(500 * time.Millisecond)
assert.EqualValues(t, 0, beforeStartCount.Load())
}
// A view that has been emptied is showing nothing, so the render it was showing is no
// longer the one to compare the next task against: running the same command again is
// putting content into the view that isn't there any more, and starts from the top.
func TestForgetRenderedContent(t *testing.T) {
resetOrigin, getResetOriginCallCount := getCounter()
manager := NewViewBufferManager(
utils.NewDummyLog(),
io.Discard,
func() {}, // beforeStart
func() {}, // refreshView
func() {}, // onEndOfInput
resetOrigin,
func() {}, // beginRender
func() {}, // swapInRender
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
runTaskToCompletion := func(key string) {
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 3}
}
done := make(chan struct{})
_ = manager.NewTask(
manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, func() { close(done) }), key)
<-done
}
runTaskToCompletion("cmd1")
assert.Equal(t, 1, getResetOriginCallCount())
// Rendering the same command's output again leaves the view where it is, that being
// what it already shows.
runTaskToCompletion("cmd1")
assert.Equal(t, 1, getResetOriginCallCount())
manager.ForgetRenderedContent()
runTaskToCompletion("cmd1")
assert.Equal(t, 2, getResetOriginCallCount(), "an emptied view is shown its content afresh")
}
func TestNewCmdTaskRefresh(t *testing.T) {
type scenario struct {
name string