mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-04 13:06:51 -04:00
Compare commits
15
Commits
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
+71
@@ -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)
|
||||
},
|
||||
})
|
||||
+70
@@ -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)
|
||||
},
|
||||
})
|
||||
+78
@@ -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`),
|
||||
|
||||
@@ -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)"),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user