mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-28 10:16:22 -04:00
Compare commits
50
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
|
||||
|
||||
@@ -845,6 +845,8 @@ keybinding:
|
||||
main:
|
||||
prevHunk: [<left>, h]
|
||||
nextHunk: [<right>, l]
|
||||
prevFile: "N"
|
||||
nextFile: "n"
|
||||
toggleSelectHunk: a
|
||||
pickBothHunks: b
|
||||
editSelectHunk: E
|
||||
|
||||
@@ -223,6 +223,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
@@ -328,6 +336,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
|
||||
@@ -192,6 +192,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
@@ -305,6 +313,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
|
||||
@@ -84,6 +84,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
@@ -162,6 +170,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
|
||||
@@ -231,6 +231,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
@@ -306,6 +314,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
|
||||
@@ -99,6 +99,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
@@ -201,6 +209,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
|
||||
@@ -235,6 +235,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
@@ -337,6 +345,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` <left>, h `` | Ir para o local anterior | |
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
@@ -106,6 +114,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
|
||||
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
|
||||
@@ -286,6 +286,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
@@ -334,6 +342,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
|
||||
@@ -75,6 +75,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -356,6 +364,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ type Hunk struct {
|
||||
newStart int
|
||||
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
|
||||
headerContext string
|
||||
// the lengths declared in the header line ('2' and '3' in the above example),
|
||||
// kept so that we can check the parsed body against them (see
|
||||
// Patch.IsWellFormed). Only set by Parse.
|
||||
declaredOldLength int
|
||||
declaredNewLength int
|
||||
// the body of the hunk, excluding the header line
|
||||
bodyLines []*PatchLine
|
||||
}
|
||||
|
||||
+26
-11
@@ -7,7 +7,9 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
|
||||
// Captures, in order: the old start, the old length (omitted by git when it is
|
||||
// 1), the new start, the new length (likewise), and the trailing context.
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
|
||||
|
||||
func Parse(patchStr string) *Patch {
|
||||
// ignore trailing newline.
|
||||
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
|
||||
var currentHunk *Hunk
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
oldStart, newStart, headerContext := headerInfo(line)
|
||||
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
|
||||
|
||||
currentHunk = &Hunk{
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
declaredOldLength: oldLength,
|
||||
declaredNewLength: newLength,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
}
|
||||
hunks = append(hunks, currentHunk)
|
||||
} else if currentHunk != nil {
|
||||
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
|
||||
}
|
||||
}
|
||||
|
||||
func headerInfo(header string) (int, int, string) {
|
||||
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
|
||||
match := hunkHeaderRegexp.FindStringSubmatch(header)
|
||||
|
||||
oldStart := utils.MustConvertToInt(match[1])
|
||||
newStart := utils.MustConvertToInt(match[2])
|
||||
headerContext := match[3]
|
||||
oldStart = utils.MustConvertToInt(match[1])
|
||||
oldLength = declaredLength(match[2])
|
||||
newStart = utils.MustConvertToInt(match[3])
|
||||
newLength = declaredLength(match[4])
|
||||
headerContext = match[5]
|
||||
|
||||
return oldStart, newStart, headerContext
|
||||
return oldStart, oldLength, newStart, newLength, headerContext
|
||||
}
|
||||
|
||||
// declaredLength parses a length capture of a hunk header, which git omits when
|
||||
// it is 1 (e.g. "@@ -0,0 +1 @@").
|
||||
func declaredLength(match string) int {
|
||||
if match == "" {
|
||||
return 1
|
||||
}
|
||||
return utils.MustConvertToInt(match)
|
||||
}
|
||||
|
||||
func newHunkLine(line string) *PatchLine {
|
||||
|
||||
@@ -79,6 +79,44 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
||||
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
|
||||
}
|
||||
|
||||
// IsWellFormed reports whether every hunk's body matches the lengths declared in
|
||||
// its header. A faithful unified diff always satisfies this; a rendering that
|
||||
// restructured the diff body does not — a diff renderer that puts line numbers in
|
||||
// a gutter, say, shifts the +/- marker off the start of each line, so every body
|
||||
// line reads as context and the computed lengths no longer match the header. That
|
||||
// makes this the test for whether a rendered diff can be parsed as a unified diff
|
||||
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
|
||||
// by Parse, which is where the declared lengths come from.
|
||||
func (self *Patch) IsWellFormed() bool {
|
||||
return self.isWellFormed(false)
|
||||
}
|
||||
|
||||
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
|
||||
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
|
||||
// so every hunk but the last has to match its header exactly, as before, while the last
|
||||
// one only has to fit within what its header declares.
|
||||
//
|
||||
// The check exists to tell a faithful rendering from a restructured one, and it still
|
||||
// does that. A rendering that moves the +/- marker off the start of the line makes us
|
||||
// read a change as context, and a context line counts towards both lengths, so such a
|
||||
// hunk comes out longer than its header declares rather than shorter.
|
||||
func (self *Patch) IsWellFormedSoFar() bool {
|
||||
return self.isWellFormed(true)
|
||||
}
|
||||
|
||||
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
|
||||
for i, hunk := range self.hunks {
|
||||
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
|
||||
return hunk.oldLength() <= hunk.declaredOldLength &&
|
||||
hunk.newLength() <= hunk.declaredNewLength
|
||||
}
|
||||
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *Patch) ContainsChanges() bool {
|
||||
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
|
||||
return hunk.containsChanges()
|
||||
@@ -114,6 +152,37 @@ func (self *Patch) LineNumberOfLine(idx int) int {
|
||||
return hunk.newStart + offset
|
||||
}
|
||||
|
||||
// Takes a line index in the patch and returns the line number in the old file.
|
||||
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
|
||||
// the line's position in the old file (additions get the position they sit at).
|
||||
// If the line is a header line, returns 1.
|
||||
// If the line is a hunk header line, returns the first old-file line number in that hunk.
|
||||
// If the line is out of range below, returns the last old-file line number in the last hunk.
|
||||
func (self *Patch) OldLineNumberOfLine(idx int) int {
|
||||
if idx < len(self.header) || len(self.hunks) == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
hunkIdx := self.HunkContainingLine(idx)
|
||||
// cursor out of range, return last file line number
|
||||
if hunkIdx == -1 {
|
||||
lastHunk := self.hunks[len(self.hunks)-1]
|
||||
return lastHunk.oldStart + lastHunk.oldLength() - 1
|
||||
}
|
||||
|
||||
hunk := self.hunks[hunkIdx]
|
||||
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
||||
idxInHunk := idx - hunkStartIdx
|
||||
|
||||
if idxInHunk == 0 {
|
||||
return hunk.oldStart
|
||||
}
|
||||
|
||||
lines := hunk.bodyLines[:idxInHunk-1]
|
||||
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
|
||||
return hunk.oldStart + offset
|
||||
}
|
||||
|
||||
// Returns hunk index containing the line at the given patch line index
|
||||
func (self *Patch) HunkContainingLine(idx int) int {
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
|
||||
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
|
||||
lemon
|
||||
`
|
||||
|
||||
// Two deletions with no line between them: they share a new-file line number
|
||||
// (both sit at the same new-file position), so only their old-file line numbers
|
||||
// tell them apart.
|
||||
const consecutiveDeletions = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
`
|
||||
|
||||
const newFile = `diff --git a/newfile b/newfile
|
||||
new file mode 100644
|
||||
index 0000000..4e680cc
|
||||
@@ -682,6 +696,148 @@ func TestLineNumberOfLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
// The body of a diff as rendered with the +/- markers moved out of the text
|
||||
// and into a gutter: every body line now reads as context, so the lengths no
|
||||
// longer match the header.
|
||||
const gutterMangled = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
`
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
patchStr string
|
||||
expected bool
|
||||
}{
|
||||
{"simpleDiff", simpleDiff, true},
|
||||
{"renameWithModificationDiff", renameWithModificationDiff, true},
|
||||
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
|
||||
{"twoHunks", twoHunks, true},
|
||||
{"consecutiveDeletions", consecutiveDeletions, true},
|
||||
{"newFile", newFile, true},
|
||||
{"deletedFile", deletedFile, true},
|
||||
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
|
||||
{"exampleHunk", exampleHunk, true},
|
||||
{"gutterMangled", gutterMangled, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormedSoFar(t *testing.T) {
|
||||
// A diff read only as far as the middle of its second hunk.
|
||||
const cutShort = `diff --git a/filename b/filename
|
||||
index e48a11c..b2ab81b 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,5 +1,5 @@
|
||||
apple
|
||||
-grape
|
||||
+orange
|
||||
...
|
||||
...
|
||||
...
|
||||
@@ -8,6 +8,8 @@ grape
|
||||
...
|
||||
...
|
||||
`
|
||||
|
||||
// The same diff cut short in its first hunk, so that the second is missing
|
||||
// entirely rather than short.
|
||||
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
|
||||
index e48a11c..b2ab81b 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,5 +1,5 @@
|
||||
apple
|
||||
-grape
|
||||
`
|
||||
|
||||
// A rendering with the +/- markers moved into a gutter, cut short: reading the
|
||||
// changes as context makes the hunk longer than its header declares, not shorter,
|
||||
// so it doesn't pass for a diff we only have the beginning of.
|
||||
const gutterMangledAndCutShort = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
melon
|
||||
`
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
patchStr string
|
||||
expected bool
|
||||
}{
|
||||
{"simpleDiff", simpleDiff, true},
|
||||
{"twoHunks", twoHunks, true},
|
||||
{"cutShort", cutShort, true},
|
||||
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
|
||||
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldLineNumberOfLine(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
indexes []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "twoChangesInOneHunk",
|
||||
patchStr: twoChangesInOneHunk,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
|
||||
},
|
||||
{
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
|
||||
},
|
||||
{
|
||||
testName: "renameWithModificationDiff",
|
||||
patchStr: renameWithModificationDiff,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, idx := range s.indexes {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.OldLineNumberOfLine(idx)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNextStageableLineIndex(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -672,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
|
||||
type KeybindingMainConfig struct {
|
||||
PrevHunk Keybinding `yaml:"prevHunk"`
|
||||
NextHunk Keybinding `yaml:"nextHunk"`
|
||||
PrevFile Keybinding `yaml:"prevFile"`
|
||||
NextFile Keybinding `yaml:"nextFile"`
|
||||
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
|
||||
PickBothHunks Keybinding `yaml:"pickBothHunks"`
|
||||
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
|
||||
@@ -1182,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Main: KeybindingMainConfig{
|
||||
PrevHunk: Keybinding{"<left>", "h"},
|
||||
NextHunk: Keybinding{"<right>", "l"},
|
||||
PrevFile: Keybinding{"N"},
|
||||
NextFile: Keybinding{"n"},
|
||||
ToggleSelectHunk: Keybinding{"a"},
|
||||
PickBothHunks: Keybinding{"b"},
|
||||
EditSelectHunk: Keybinding{"E"},
|
||||
|
||||
+97
-20
@@ -20,6 +20,25 @@ type escapeInterpreter struct {
|
||||
instruction instruction
|
||||
hyperlink strings.Builder
|
||||
|
||||
// the digits of the OSC number seen so far, while we don't yet know which
|
||||
// OSC this is
|
||||
oscNumber strings.Builder
|
||||
// the payload of an OSC 1717 sequence, in which a diff renderer states
|
||||
// which line of which file it is about to render; accumulated like
|
||||
// hyperlink, and attached to the cells that follow it
|
||||
metadata strings.Builder
|
||||
// whether the payload currently in metadata has reached a cell, so that one
|
||||
// that never does can be recognized and kept as an orphan
|
||||
metadataConsumed bool
|
||||
// OSC 1717 payloads that no cell took, because the next record followed
|
||||
// with nothing rendered in between. A renderer emits records back to back
|
||||
// wherever two diff lines share a rendered line — the deletion and the
|
||||
// addition of a modification collapsed into one column, or a banner
|
||||
// announcing a file and its first hunk at once. The write loop gives these
|
||||
// cells of their own, so that a line keeps every record it was given rather
|
||||
// than only the last.
|
||||
orphanedMetadata []string
|
||||
|
||||
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
|
||||
// rows rather than emitting LFs for them. To convert those into row
|
||||
// advances the view can act on, we track where in the pseudo-terminal
|
||||
@@ -82,9 +101,9 @@ const (
|
||||
stateParams
|
||||
stateCSIDiscard
|
||||
stateOSC
|
||||
stateOSCWaitForParams
|
||||
stateOSCParams
|
||||
stateOSCHyperlink
|
||||
stateOSCMetadata
|
||||
stateOSCEndEscape
|
||||
stateOSCSkipUnknown
|
||||
|
||||
@@ -427,27 +446,42 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
}
|
||||
return true, nil
|
||||
case stateOSC:
|
||||
if characterEquals(ch, '8') {
|
||||
ei.state = stateOSCWaitForParams
|
||||
ei.hyperlink.Reset()
|
||||
// Accumulate the OSC number until the ';' that terminates it, then
|
||||
// dispatch on the whole number rather than on a single digit.
|
||||
switch {
|
||||
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
|
||||
ei.oscNumber.WriteByte(ch[0])
|
||||
return true, nil
|
||||
case characterEquals(ch, ';'):
|
||||
switch ei.oscNumber.String() {
|
||||
case "8":
|
||||
ei.hyperlink.Reset()
|
||||
ei.state = stateOSCParams
|
||||
case "1717":
|
||||
ei.orphanUnconsumedMetadata()
|
||||
ei.state = stateOSCMetadata
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
ei.oscNumber.Reset()
|
||||
return true, nil
|
||||
default:
|
||||
// Not an OSC we understand — it has no number, or a character
|
||||
// follows the number where the ';' should be. Rather than
|
||||
// erroring, which would reset state mid-OSC and leak the rest of
|
||||
// the sequence into the view as literal text, skip to its
|
||||
// terminator, which this character may already be.
|
||||
ei.oscNumber.Reset()
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
case stateOSCWaitForParams:
|
||||
if !characterEquals(ch, ';') {
|
||||
// Malformed OSC 8 (expected ';' after '8'). Rather than
|
||||
// erroring — which would reset state mid-OSC and cause the
|
||||
// rest of the sequence to leak as literal text — treat the
|
||||
// whole OSC as one we don't understand and skip to its
|
||||
// terminator.
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCParams
|
||||
return true, nil
|
||||
case stateOSCParams:
|
||||
if characterEquals(ch, ';') {
|
||||
ei.state = stateOSCHyperlink
|
||||
@@ -463,6 +497,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
ei.hyperlink.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCMetadata:
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.metadata.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCEndEscape:
|
||||
ei.state = stateNone
|
||||
return true, nil
|
||||
@@ -478,6 +524,37 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
|
||||
// record, keeping the payload it held as an orphan if no cell took it (see
|
||||
// orphanedMetadata).
|
||||
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
|
||||
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
|
||||
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
|
||||
}
|
||||
ei.metadata.Reset()
|
||||
ei.metadataConsumed = false
|
||||
}
|
||||
|
||||
// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and
|
||||
// clears the list.
|
||||
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
|
||||
result := ei.orphanedMetadata
|
||||
ei.orphanedMetadata = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that
|
||||
// carries nothing beyond the version. A diff renderer emits such a record ahead
|
||||
// of everything else to announce that it speaks the protocol, so that a host can
|
||||
// find that out by asking rather than by inspecting a rendering. It says nothing
|
||||
// about any line, so it must not attach to the line that follows it; a per-line
|
||||
// record always has fields, and is kept.
|
||||
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
|
||||
if !strings.Contains(ei.metadata.String(), ";") {
|
||||
ei.metadata.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (ei *escapeInterpreter) outputCSI() error {
|
||||
n := len(ei.csiParam)
|
||||
for i := 0; i < n; {
|
||||
|
||||
@@ -167,6 +167,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
|
||||
"\x1b[0 q", // intermediate byte after a param
|
||||
"\x1b[1;;m", // malformed SGR: empty middle param
|
||||
"\x1b]8bogus\x07", // OSC 8 missing ';'
|
||||
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
|
||||
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
|
||||
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
|
||||
}
|
||||
|
||||
+2
-2
@@ -438,7 +438,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
|
||||
v.y1 = y1
|
||||
|
||||
if sizeChanged {
|
||||
v.ClearViewLines()
|
||||
v.RewrapContent()
|
||||
|
||||
if v.Editable {
|
||||
cursorX, cursorY := v.TextArea.GetCursorXY()
|
||||
@@ -1576,7 +1576,7 @@ func (g *Gui) flush() error {
|
||||
// if GUI's size has changed, we need to redraw all views
|
||||
if maxX != g.maxX || maxY != g.maxY {
|
||||
for _, v := range g.views {
|
||||
v.ClearViewLines()
|
||||
v.RewrapContent()
|
||||
}
|
||||
}
|
||||
g.maxX, g.maxY = maxX, maxY
|
||||
|
||||
+427
-25
@@ -5,6 +5,7 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
@@ -163,6 +164,12 @@ type View struct {
|
||||
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
|
||||
HighlightInactive bool
|
||||
|
||||
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
|
||||
// in the selection colors on that many columns at its left edge only, rather
|
||||
// than across its whole width, leaving the line's own colors to show through.
|
||||
// For content that conveys meaning by color of its own.
|
||||
SelectedLineColorWidth int
|
||||
|
||||
// If Frame is true, a border will be drawn around the view.
|
||||
Frame bool
|
||||
|
||||
@@ -251,23 +258,138 @@ type pos struct {
|
||||
x, y int
|
||||
}
|
||||
|
||||
// call this in the event of a view resize, or if you want to render new content
|
||||
// without the chance of old content still appearing, or if you want to remove
|
||||
// a line from the existing content
|
||||
// call this if you want to render new content without the chance of old content
|
||||
// still appearing, or if you want to remove a line from the existing content. For
|
||||
// a view whose size has changed, whose content is the same but has to be wrapped
|
||||
// afresh, call RewrapContent instead.
|
||||
func (v *View) clearViewLines() {
|
||||
v.tainted = true
|
||||
v.viewLines = nil
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
|
||||
// the UI thread (the layout pass) that touch a view whose content a task
|
||||
// goroutine may be writing concurrently: viewLines/tainted/hover are all
|
||||
// buffer state that writeMutex protects.
|
||||
func (v *View) ClearViewLines() {
|
||||
// RewrapContent wraps the view's content for the size the view has now, and puts
|
||||
// the positions into that content — the scroll offset, the cursor, a range's
|
||||
// anchor — back on the lines they were on. They are all view lines, which count
|
||||
// the segments each line is wrapped into, so wrapping the content at another
|
||||
// width leaves every one of them pointing at a different line.
|
||||
//
|
||||
// Call it on the UI thread whenever the view's size changes; a task goroutine may
|
||||
// be writing the content concurrently, and all of this is state writeMutex
|
||||
// protects.
|
||||
func (v *View) RewrapContent() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
origin := v.contentPosOf(v.oy)
|
||||
cursor := v.contentPosOf(v.oy + v.cy)
|
||||
anchor := v.contentPosOf(v.rangeSelectStartY)
|
||||
cursorRow := v.cy
|
||||
|
||||
v.clearViewLines()
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if !origin.ok {
|
||||
return
|
||||
}
|
||||
|
||||
cursorLine, cursorOk := v.viewLineOf(cursor)
|
||||
if anchorLine, ok := v.viewLineOf(anchor); ok {
|
||||
v.rangeSelectStartY = anchorLine
|
||||
if cursorOk {
|
||||
// A range covers lines of content, not the wrapped segments those
|
||||
// lines are drawn as, so its ends go back on the outermost segments
|
||||
// of their lines: a line that was covered whole stays covered whole.
|
||||
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
|
||||
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
|
||||
// move under the user; with no cursor on screen the view keeps its own place
|
||||
// in the content instead.
|
||||
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
|
||||
v.SetOriginY(cursorLine - cursorRow)
|
||||
} else if originLine, ok := v.viewLineOf(origin); ok {
|
||||
v.SetOriginY(originLine)
|
||||
}
|
||||
if cursorOk {
|
||||
v.cy = cursorLine - v.oy
|
||||
}
|
||||
}
|
||||
|
||||
// contentPos is a position in a view's content in terms that survive the content
|
||||
// being wrapped again: which line of it, and which of that line's segments.
|
||||
type contentPos struct {
|
||||
line, segment int
|
||||
ok bool
|
||||
}
|
||||
|
||||
// contentPosOf returns where the given view line sits in the content. Only call
|
||||
// this with a lock on writeMutex, and with the view lines up to date.
|
||||
func (v *View) contentPosOf(viewLine int) contentPos {
|
||||
if viewLine < 0 || viewLine >= len(v.viewLines) {
|
||||
return contentPos{}
|
||||
}
|
||||
return contentPos{
|
||||
line: v.viewLines[viewLine].linesY,
|
||||
segment: v.viewLines[viewLine].linesX,
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
// viewLineOf returns the view line drawing the given position in the content,
|
||||
// on the nearest segment its line still has. Only call this with a lock on
|
||||
// writeMutex, and with the view lines up to date.
|
||||
func (v *View) viewLineOf(pos contentPos) (int, bool) {
|
||||
first, last, ok := v.segmentSpanOf(pos)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return min(first+pos.segment, last), true
|
||||
}
|
||||
|
||||
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
|
||||
// outermost segment of its line, so that the range covers that line whole. other
|
||||
// is the range's other end, which says which way is outward. Both ends have to be
|
||||
// positions whose lines are drawn, which viewLineOf answers.
|
||||
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
|
||||
first, last, _ := v.segmentSpanOf(pos)
|
||||
if pos.line <= other.line {
|
||||
return first
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// segmentSpanOf returns the first and last view line drawing the given position's
|
||||
// line of the content. ok is false when the position was never taken, or its line
|
||||
// isn't drawn at all.
|
||||
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
|
||||
if !pos.ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return v.viewLineSpanOfBufferLine(pos.line)
|
||||
}
|
||||
|
||||
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
|
||||
// buffer line, i.e. the first and last segment it is wrapped into. Both are the
|
||||
// same view line when the line doesn't wrap. ok is false when the line isn't drawn
|
||||
// at all. Only call this with a lock on writeMutex, and with the view lines up to
|
||||
// date.
|
||||
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
|
||||
first, last := -1, -1
|
||||
for i, vline := range v.viewLines {
|
||||
if vline.linesY == bufferLine {
|
||||
if first == -1 {
|
||||
first = i
|
||||
}
|
||||
last = i
|
||||
} else if first != -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return first, last, first != -1
|
||||
}
|
||||
|
||||
type searcher struct {
|
||||
@@ -505,10 +627,23 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
|
||||
v.rangeSelectStartY = rangeSelectStartY
|
||||
}
|
||||
|
||||
// RangeSelectStartY returns the view line the range selection is anchored on,
|
||||
// or -1 when there is no range.
|
||||
func (v *View) RangeSelectStartY() int {
|
||||
return v.rangeSelectStartY
|
||||
}
|
||||
|
||||
func (v *View) CancelRangeSelect() {
|
||||
v.rangeSelectStartY = -1
|
||||
}
|
||||
|
||||
// HasRangeSelect reports whether a range selection is anchored, as opposed to the
|
||||
// view showing a plain cursor. A range whose ends are on the same view line is still
|
||||
// one, which SelectedLineRange alone can't tell you.
|
||||
func (v *View) HasRangeSelect() bool {
|
||||
return v.rangeSelectStartY != -1
|
||||
}
|
||||
|
||||
func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int {
|
||||
if viewHeight >= lineCount {
|
||||
return 0
|
||||
@@ -578,6 +713,23 @@ type lineType struct {
|
||||
// matches the current width; nil means nothing is cached yet.
|
||||
wrappedCells [][]cell
|
||||
wrappedColumns int
|
||||
|
||||
// asWritten is the text of the line as its writer wrote it, escape sequences
|
||||
// left out, kept from the first character on that the cells spell differently:
|
||||
// a tab, which the cells hold as the spaces it fills, or a carriage return,
|
||||
// which they hold as the overwrite it caused. nil while the cells spell the
|
||||
// line as it was written, as they do for most lines.
|
||||
asWritten []byte
|
||||
}
|
||||
|
||||
// textAsWritten returns the line's text as its writer wrote it, escape sequences
|
||||
// left out. A reader that parses a view's content rather than showing it wants
|
||||
// this form; the cells' text is for showing.
|
||||
func (l *lineType) textAsWritten() string {
|
||||
if l.asWritten != nil {
|
||||
return string(l.asWritten)
|
||||
}
|
||||
return l.cells.String()
|
||||
}
|
||||
|
||||
// trailingFillAttributes describes the fg/bg colors that draw() should
|
||||
@@ -594,6 +746,9 @@ type cell struct {
|
||||
width int // number of terminal cells occupied by chr (always 1 or 2)
|
||||
bgColor, fgColor Attribute
|
||||
hyperlink string
|
||||
// the OSC 1717 payload in effect when the cell was written, i.e. what the
|
||||
// diff renderer said about the diff line this cell is part of
|
||||
metadata string
|
||||
}
|
||||
|
||||
type cells []cell
|
||||
@@ -727,7 +882,8 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
|
||||
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
|
||||
}
|
||||
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
||||
colorWidth := v.SelectedLineColorWidth
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
|
||||
fgColor = applySelTextColor(fgColor, v.SelTextColor)
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
@@ -835,8 +991,7 @@ func (v *View) SetWritePos(x, y int) {
|
||||
y = 0
|
||||
}
|
||||
|
||||
v.buf.wx = x
|
||||
v.buf.wy = y
|
||||
v.buf.seekWrite(x, y)
|
||||
|
||||
// Changing the write position makes a pending newline obsolete
|
||||
v.buf.pendingNewline = false
|
||||
@@ -920,6 +1075,35 @@ func (b *viewBuffer) writeCells(cells []cell) {
|
||||
b.wx += len(cells)
|
||||
}
|
||||
|
||||
// seekWrite moves the write cursor to (x, y). Writing there starts the line over,
|
||||
// so whatever it kept of its text as written is dropped; the text a line keeps is
|
||||
// the text written to it from its start. A carriage return continues a line
|
||||
// instead, and moves the cursor without this (see write).
|
||||
func (b *viewBuffer) seekWrite(x, y int) {
|
||||
b.wx = x
|
||||
b.wy = y
|
||||
if y < len(b.lines) {
|
||||
b.lines[y].asWritten = nil
|
||||
}
|
||||
}
|
||||
|
||||
// startAsWritten begins keeping the current line's text as written (see
|
||||
// lineType.asWritten), at the first character the cells won't spell the same way.
|
||||
// Up to here they spell it exactly, so their text is what was written so far.
|
||||
func (b *viewBuffer) startAsWritten() {
|
||||
if line := &b.lines[b.wy]; line.asWritten == nil {
|
||||
line.asWritten = append([]byte{}, line.cells.String()...)
|
||||
}
|
||||
}
|
||||
|
||||
// noteAsWritten records text the writer wrote to the current line, once the line
|
||||
// keeps its text as written at all.
|
||||
func (b *viewBuffer) noteAsWritten(text []byte) {
|
||||
if line := &b.lines[b.wy]; line.asWritten != nil {
|
||||
line.asWritten = append(line.asWritten, text...)
|
||||
}
|
||||
}
|
||||
|
||||
// Write appends a byte slice into the view's internal buffer. Because
|
||||
// View implements the io.Writer interface, it can be passed as parameter
|
||||
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
|
||||
@@ -963,14 +1147,30 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
|
||||
finishLine := func() {
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
// A record that reached the line's end without covering a cell still
|
||||
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
|
||||
// or the record of a changed line that is empty, which a renderer emits
|
||||
// with nothing but the newline after it. Give each a cell of its own, so
|
||||
// that the line is still recognizable as the diff line it renders rather
|
||||
// than as nothing at all.
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
b.writeCells([]cell{{metadata: payload}})
|
||||
}
|
||||
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
|
||||
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
|
||||
b.ei.metadataConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
advanceToNextLine := func() {
|
||||
b.wx = 0
|
||||
b.wy++
|
||||
b.seekWrite(0, b.wy+1)
|
||||
if b.wy >= len(b.lines) {
|
||||
b.lines = append(b.lines, lineType{})
|
||||
}
|
||||
// An OSC 1717 record describes the line it precedes and is never
|
||||
// closed, so it stops applying at the line's end; a renderer emits a
|
||||
// fresh one for each line it has something to say about.
|
||||
b.ei.metadata.Reset()
|
||||
}
|
||||
|
||||
if b.pendingNewline {
|
||||
@@ -1000,6 +1200,10 @@ func (b *viewBuffer) write(v *View, p []byte) {
|
||||
b.ei.notifyRowAdvance()
|
||||
case characterEquals(chr, '\r'):
|
||||
finishLine()
|
||||
// The cells will hold what follows as an overwrite of what came
|
||||
// before; the text as written keeps the return itself.
|
||||
b.startAsWritten()
|
||||
b.noteAsWritten(chr)
|
||||
b.wx = 0
|
||||
b.ei.notifyColumnReset()
|
||||
default:
|
||||
@@ -1115,8 +1319,18 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
truncateLine := false
|
||||
|
||||
isEscape, err := b.ei.parseOne(ch)
|
||||
|
||||
// A record that the next one superseded before any cell took it still
|
||||
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
|
||||
// a cell of its own, in the order they were emitted, ahead of whatever this
|
||||
// character produces.
|
||||
for _, payload := range b.ei.takeOrphanedMetadata() {
|
||||
cells = append(cells, cell{metadata: payload})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
for _, chr := range b.ei.characters() {
|
||||
characters := b.ei.characters()
|
||||
for _, chr := range characters {
|
||||
c := cell{
|
||||
fgColor: v.FgColor,
|
||||
bgColor: v.BgColor,
|
||||
@@ -1125,6 +1339,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
}
|
||||
cells = append(cells, c)
|
||||
}
|
||||
b.noteAsWritten([]byte(strings.Join(characters, "")))
|
||||
b.ei.reset()
|
||||
} else {
|
||||
repeatCount := 1
|
||||
@@ -1141,7 +1356,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
fg: b.ei.curFgColor,
|
||||
bg: b.ei.curBgColor,
|
||||
}
|
||||
return truncateLine, []cell{}
|
||||
return truncateLine, cells
|
||||
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
|
||||
// emit `n` space cells under the parser-tracked SGR — used
|
||||
// to materialize ConPTY's compressed runs of spaces (which
|
||||
@@ -1150,10 +1365,19 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
repeatCount = cf.n
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
b.noteAsWritten(bytes.Repeat(ch, repeatCount))
|
||||
} else if isEscape {
|
||||
// do not output anything
|
||||
return truncateLine, nil
|
||||
// the escape itself outputs nothing, but any cells carrying an
|
||||
// orphaned record still need writing
|
||||
if len(cells) == 0 {
|
||||
return truncateLine, nil
|
||||
}
|
||||
return truncateLine, cells
|
||||
} else if characterEquals(ch, '\t') {
|
||||
// The cells hold a tab as the spaces it fills; the text as written
|
||||
// keeps the tab itself.
|
||||
b.startAsWritten()
|
||||
b.noteAsWritten(ch)
|
||||
// fill tab-sized space
|
||||
tabWidth := v.TabWidth
|
||||
if tabWidth < 1 {
|
||||
@@ -1162,14 +1386,20 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
repeatCount = tabWidth - (x % tabWidth)
|
||||
} else {
|
||||
b.noteAsWritten(ch)
|
||||
}
|
||||
c := cell{
|
||||
fgColor: b.ei.curFgColor,
|
||||
bgColor: b.ei.curBgColor,
|
||||
hyperlink: b.ei.hyperlink.String(),
|
||||
metadata: b.ei.metadata.String(),
|
||||
chr: string(ch),
|
||||
width: width,
|
||||
}
|
||||
if c.metadata != "" {
|
||||
b.ei.metadataConsumed = true
|
||||
}
|
||||
for range repeatCount {
|
||||
cells = append(cells, c)
|
||||
}
|
||||
@@ -1254,9 +1484,9 @@ func (v *View) CopyContent(from *View) {
|
||||
|
||||
// A background task may be streaming output into the source view's buffer
|
||||
// via Write, so read it under its own lock. The source is always a
|
||||
// different view than the destination (see the sole caller,
|
||||
// moveMainContextToTop), and no other code holds two view write locks at
|
||||
// once, so this can't deadlock.
|
||||
// different view than the destination — its callers hand content from one
|
||||
// view to another — and no other code holds two view write locks at once, so
|
||||
// this can't deadlock.
|
||||
from.writeMutex.Lock()
|
||||
defer from.writeMutex.Unlock()
|
||||
|
||||
@@ -1745,6 +1975,139 @@ func (v *View) BufferLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
|
||||
// to recover which line of which file it came from: the line's text, which can be
|
||||
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
|
||||
// records a diff renderer attached to it, which state the answer outright.
|
||||
type DiffLineContent struct {
|
||||
// The line's text as its writer wrote it, escape sequences left out, where
|
||||
// BufferLines gives the text as the cells spell it. The two differ where the
|
||||
// cells can't spell what was written: a tab, which they hold as the spaces it
|
||||
// fills, and a carriage return, which they hold as the overwrite it caused.
|
||||
// git terminates a path containing a space with a tab in a diff header, and a
|
||||
// parser of the diff has to see the tab.
|
||||
Text string
|
||||
// The distinct OSC 1717 payloads carried by the line's cells, in
|
||||
// left-to-right order. A single-column rendering tags every cell of a line
|
||||
// with the same payload, so there is one; a side-by-side rendering tags
|
||||
// each side separately, so a line showing a deletion beside the addition
|
||||
// that replaces it carries both.
|
||||
Metadata []string
|
||||
}
|
||||
|
||||
// DiffLineContents returns the per-line material a diff-line reader works from
|
||||
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
|
||||
// snapshotted in a single locked pass, so they stay consistent with each other
|
||||
// and with the buffer they came from even while a re-render rebuilds it.
|
||||
func (v *View) DiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return diffLineContentsFrom(v.buf, 0)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContents is DiffLineContents for the content of a re-render in
|
||||
// progress (see BeginOffscreenRender). A reader deciding where the new content
|
||||
// should be shown has to work from this: it has to answer before the swap, since
|
||||
// after the swap the content is already on screen. Returns nil when no re-render
|
||||
// is underway.
|
||||
func (v *View) OffscreenDiffLineContents() []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return nil
|
||||
}
|
||||
return diffLineContentsFrom(v.offscreen, 0)
|
||||
}
|
||||
|
||||
// OffscreenDiffLineContentsFrom is OffscreenDiffLineContents restricted to the lines
|
||||
// from index `from` on (so result[0] is buffer line `from`). It lets a reader that
|
||||
// follows a re-render as it loads look at each line once, rather than snapshotting
|
||||
// the whole buffer again on every line — the difference between an O(n) and an O(n²)
|
||||
// scan of a large diff. Returns nil when no re-render is underway, or when `from` is
|
||||
// past the lines read so far.
|
||||
func (v *View) OffscreenDiffLineContentsFrom(from int) []DiffLineContent {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil || from < 0 || from >= len(v.offscreen.lines) {
|
||||
return nil
|
||||
}
|
||||
return diffLineContentsFrom(v.offscreen, from)
|
||||
}
|
||||
|
||||
// OffscreenLineCount returns the number of unwrapped lines a re-render in progress
|
||||
// has read so far, or 0 when none is underway. It tells a reader waiting for a
|
||||
// particular line, cheaply, when a screenful below it has arrived too — so that the
|
||||
// swap shows that line with content under it rather than at the bottom edge of a
|
||||
// half-filled view.
|
||||
func (v *View) OffscreenLineCount() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return 0
|
||||
}
|
||||
return len(v.offscreen.lines)
|
||||
}
|
||||
|
||||
func diffLineContentsFrom(buf *viewBuffer, from int) []DiffLineContent {
|
||||
lines := buf.lines[from:]
|
||||
contents := make([]DiffLineContent, len(lines))
|
||||
for i := range lines {
|
||||
line := &lines[i]
|
||||
var metadata []string
|
||||
for _, c := range line.cells {
|
||||
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
|
||||
metadata = append(metadata, c.metadata)
|
||||
}
|
||||
}
|
||||
contents[i] = DiffLineContent{Text: line.textAsWritten(), Metadata: metadata}
|
||||
}
|
||||
return contents
|
||||
}
|
||||
|
||||
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
|
||||
// the index of the corresponding line in the unwrapped internal buffer (as
|
||||
// returned by BufferLines). Several view lines map to the same buffer line when
|
||||
// that line wraps. Returns false if the view line is out of range.
|
||||
func (v *View) BufferLineForViewLine(y int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return v.bufferLineForViewLine(y)
|
||||
}
|
||||
|
||||
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
|
||||
// first view line that renders it — the inverse of BufferLineForViewLine, for
|
||||
// turning a line found by examining the buffer into a line to scroll to or
|
||||
// select. Returns false if the buffer line isn't rendered into any view line.
|
||||
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
|
||||
return first, ok
|
||||
}
|
||||
|
||||
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
|
||||
// the last view line that renders it, which for a line that doesn't wrap is the
|
||||
// same as the first. It is where the far end of a range goes: a range is over
|
||||
// buffer lines, so it has to cover the last one of them to its final segment
|
||||
// rather than stopping where that line begins.
|
||||
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
|
||||
return last, ok
|
||||
}
|
||||
|
||||
// Buffer returns a string with the contents of the view's internal
|
||||
// buffer.
|
||||
func (v *View) Buffer() string {
|
||||
@@ -1771,6 +2134,9 @@ func (v *View) ViewBufferLines() []string {
|
||||
|
||||
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
|
||||
func (v *View) LinesHeight() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return len(v.buf.lines)
|
||||
}
|
||||
|
||||
@@ -1980,16 +2346,31 @@ func (v *View) SelectedLineIdx() int {
|
||||
return seletedLineIdx
|
||||
}
|
||||
|
||||
// IsLineVisible reports whether the given view line is one of those on screen.
|
||||
func (v *View) IsLineVisible(viewLine int) bool {
|
||||
return viewLine >= v.OriginY() && viewLine < v.OriginY()+v.InnerHeight()
|
||||
}
|
||||
|
||||
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
|
||||
// stands in for a cursor in a view that has none: of the lines on screen, the one in
|
||||
// the middle is the likeliest to be the one being read.
|
||||
func (v *View) MiddleVisibleLineIdx() int {
|
||||
top := v.OriginY()
|
||||
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
|
||||
return (top + bottom) / 2
|
||||
}
|
||||
|
||||
// expected to only be used in tests
|
||||
func (v *View) SelectedLine() string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if len(v.buf.lines) == 0 {
|
||||
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v.lineContentAtIdx(v.SelectedLineIdx())
|
||||
return v.lineContentAtIdx(idx)
|
||||
}
|
||||
|
||||
// expected to only be used in tests
|
||||
@@ -2004,8 +2385,17 @@ func (v *View) SelectedLines() []string {
|
||||
startIdx, endIdx := v.SelectedLineRange()
|
||||
|
||||
lines := make([]string, 0, endIdx-startIdx+1)
|
||||
previous := -1
|
||||
for i := startIdx; i <= endIdx; i++ {
|
||||
lines = append(lines, v.lineContentAtIdx(i))
|
||||
// The selection is in view lines, which count the segments a wrapped line
|
||||
// is drawn as; a line the selection covers several segments of is still
|
||||
// the one line it is.
|
||||
idx, ok := v.bufferLineForViewLine(i)
|
||||
if !ok || idx == previous {
|
||||
continue
|
||||
}
|
||||
previous = idx
|
||||
lines = append(lines, v.lineContentAtIdx(idx))
|
||||
}
|
||||
|
||||
return lines
|
||||
@@ -2015,6 +2405,19 @@ func (v *View) lineContentAtIdx(idx int) string {
|
||||
return v.buf.lines[idx].cells.String()
|
||||
}
|
||||
|
||||
// bufferLineForViewLine maps a view line index, which counts the wrapped
|
||||
// segments of the lines it draws, to the index of the line of content it is a
|
||||
// segment of. Only call this with a lock on writeMutex.
|
||||
func (v *View) bufferLineForViewLine(y int) (int, bool) {
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return v.viewLines[y].linesY, true
|
||||
}
|
||||
|
||||
func (v *View) SelectedPoint() (int, int) {
|
||||
cx, cy := v.Cursor()
|
||||
ox, oy := v.Origin()
|
||||
@@ -2085,8 +2488,7 @@ func (v *View) ClearTextArea() {
|
||||
|
||||
func (v *View) overwriteLines(y int, content string) {
|
||||
// break by newline, then for each line, write it, then add that erase command
|
||||
v.buf.wx = 0
|
||||
v.buf.wy = y
|
||||
v.SetWritePos(0, y)
|
||||
v.clearViewLines()
|
||||
|
||||
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
|
||||
|
||||
@@ -113,6 +113,99 @@ func TestWriteString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
// The trailing newline is held back until more content arrives, so that the
|
||||
// view doesn't end in an empty line.
|
||||
v.writeString("a\nb\n")
|
||||
|
||||
v.OverwriteLines(0, "x")
|
||||
|
||||
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
|
||||
}
|
||||
|
||||
// diffLineTexts returns the Text of each of the given contents.
|
||||
func diffLineTexts(contents []DiffLineContent) []string {
|
||||
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
|
||||
}
|
||||
|
||||
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stringsToWrite []string
|
||||
expectedShown []string
|
||||
expectedAsWritten []string
|
||||
}{
|
||||
{
|
||||
name: "a line the cells spell as written",
|
||||
stringsToWrite: []string{"abc\n"},
|
||||
expectedShown: []string{"abc"},
|
||||
expectedAsWritten: []string{"abc"},
|
||||
},
|
||||
{
|
||||
name: "a tab is kept rather than the spaces it fills",
|
||||
stringsToWrite: []string{"a\tb\n"},
|
||||
expectedShown: []string{"a b"},
|
||||
expectedAsWritten: []string{"a\tb"},
|
||||
},
|
||||
{
|
||||
name: "a carriage return is kept rather than the overwrite it causes",
|
||||
stringsToWrite: []string{"abc\rde\n"},
|
||||
expectedShown: []string{"dec"},
|
||||
expectedAsWritten: []string{"abc\rde"},
|
||||
},
|
||||
{
|
||||
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
|
||||
name: "escape sequences are left out",
|
||||
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
|
||||
expectedShown: []string{"+foo"},
|
||||
expectedAsWritten: []string{"+foo\r"},
|
||||
},
|
||||
{
|
||||
// ConPTY writes a run of spaces as a cursor-forward escape.
|
||||
name: "a cursor-forward escape stands for the spaces it skips",
|
||||
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
|
||||
expectedShown: []string{" a b"},
|
||||
expectedAsWritten: []string{"\ta b"},
|
||||
},
|
||||
{
|
||||
name: "a line written in two parts",
|
||||
stringsToWrite: []string{"a\t", "b\n"},
|
||||
expectedShown: []string{"a b"},
|
||||
expectedAsWritten: []string{"a\tb"},
|
||||
},
|
||||
{
|
||||
name: "only the lines with a tab or a return are kept separately",
|
||||
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
|
||||
expectedShown: []string{"x", "y z", "w"},
|
||||
expectedAsWritten: []string{"x", "y\tz", "w"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
assert.Equal(t, test.expectedShown, v.BufferLines())
|
||||
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 20, 10, OutputNormal)
|
||||
v.writeString("a\tb\nc\td")
|
||||
|
||||
// Overwriting a line starts it over: what it kept of its earlier text goes,
|
||||
// and the line below is left alone.
|
||||
v.OverwriteLines(0, "xy")
|
||||
|
||||
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
|
||||
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
|
||||
}
|
||||
|
||||
func TestUpdatedCursorAndOrigin(t *testing.T) {
|
||||
tests := []struct {
|
||||
prevOrigin int
|
||||
@@ -158,6 +251,107 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
|
||||
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
|
||||
func osc1717(payload string) string {
|
||||
return "\x1b]1717;" + payload + "\x1b\\"
|
||||
}
|
||||
|
||||
func TestDiffLineContents(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A diff renderer prefixes each line it renders with a record naming the
|
||||
// file and the line's position in it: version;type;new-line;old-line;file.
|
||||
v.writeString(strings.Join([]string{
|
||||
osc1717("1;c;1;;foo.txt") + "line1",
|
||||
osc1717("1;d;2;2;foo.txt") + "old2",
|
||||
osc1717("1;a;2;;foo.txt") + "new2",
|
||||
"@@ a hunk header, which carries no record @@",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
|
||||
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
|
||||
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
|
||||
// The record of the line before doesn't bleed onto this one.
|
||||
{Text: "@@ a hunk header, which carries no record @@"},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A side-by-side renderer puts two diff lines on one rendered line, and so
|
||||
// emits a record before each half.
|
||||
v.writeString(strings.Join([]string{
|
||||
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
|
||||
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
// The two halves of a context line are the same diff line, stated twice.
|
||||
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
|
||||
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A line that gocui wraps is still one buffer line, so its record covers
|
||||
// every view line it is displayed on.
|
||||
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
for viewLine := range 3 {
|
||||
bufferLine, ok := v.BufferLineForViewLine(viewLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, bufferLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString(strings.Join([]string{
|
||||
// A banner announcing a file and its first hunk at once carries both
|
||||
// records back to back.
|
||||
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
|
||||
// So does a modification whose deletion and addition are collapsed into
|
||||
// a single rendered line.
|
||||
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
|
||||
// A changed line that is empty is rendered as its record and nothing else.
|
||||
osc1717("1;a;6;;foo.txt"),
|
||||
}, "\n") + "\n")
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
|
||||
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
|
||||
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A diff renderer announces itself with a version-only record before the
|
||||
// diff. It must leave no trace: no visible bytes, no line of its own, and
|
||||
// above all no record on the line that follows it.
|
||||
v.writeString(osc1717("1") + strings.Join([]string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
osc1717("1;a;1;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "diff --git a/foo.txt b/foo.txt"},
|
||||
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
|
||||
}, v.DiffLineContents())
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
@@ -204,6 +398,61 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
|
||||
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
func TestBufferLineForViewLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
|
||||
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
|
||||
bufferLine, ok := v.BufferLineForViewLine(viewLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantBufferLine, bufferLine)
|
||||
}
|
||||
|
||||
_, ok := v.BufferLineForViewLine(5)
|
||||
assert.False(t, ok)
|
||||
|
||||
_, ok = v.BufferLineForViewLine(-1)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestViewLineForBufferLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A wrapped buffer line maps to the first of the view lines it spans.
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for bufferLine, wantViewLine := range []int{0, 1, 4} {
|
||||
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.ViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestLastViewLineForBufferLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// A wrapped buffer line maps to the last of the view lines it spans.
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for bufferLine, wantViewLine := range []int{0, 3, 4} {
|
||||
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.LastViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
@@ -820,3 +1069,53 @@ func TestApplySelTextColor(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A view that wraps draws one line of its content as several view lines, and the
|
||||
// cursor and the range anchor count those. What is asked about a selection is
|
||||
// which lines of the content it covers, so those are what it has to be reported
|
||||
// in.
|
||||
func TestSelectedLinesOfWrappedContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
|
||||
// "a line that wraps" takes two view lines, so the four lines of content are
|
||||
// drawn as five: "one", "two", "a line th", "at wraps", "four".
|
||||
v.writeString("one\ntwo\na line that wraps\nfour\n")
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
// The cursor on the wrapped line's second half is on that line.
|
||||
v.FocusPoint(0, 3, false)
|
||||
assert.Equal(t, "a line that wraps", v.SelectedLine())
|
||||
|
||||
// A range over both halves of the wrapped line covers one line of content.
|
||||
v.SetRangeSelectStart(2)
|
||||
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
// Resizing a view throws away the wrapping of its content and wraps it again for
|
||||
// the new width, which moves every line of it to a different view line. The
|
||||
// positions into the view count view lines, so they all have to come along.
|
||||
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
|
||||
g := &Gui{}
|
||||
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
|
||||
// Two wrapping lines, with a single line between them: eight view lines for
|
||||
// five lines of content.
|
||||
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
|
||||
assert.Equal(t, 8, v.ViewLinesHeight())
|
||||
|
||||
// A range over the whole of the second wrapping line, which is drawn as view
|
||||
// lines 4 to 6.
|
||||
v.SetRangeSelectStart(4)
|
||||
v.FocusPoint(0, 6, false)
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
|
||||
// Widen the view so that nothing wraps any more.
|
||||
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
+14
-3
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
||||
|
||||
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
|
||||
|
||||
self.updateSelectionHighlights()
|
||||
self.UpdateSelectionHighlights()
|
||||
|
||||
c.HandleFocus(opts)
|
||||
}
|
||||
|
||||
// updateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
|
||||
// them draw theirs as the active one: a view shows a selection while its context is
|
||||
// on the stack and has something to select, and the context the user is in shows the
|
||||
// active selection while the ones behind it show inactive ones.
|
||||
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
|
||||
// every change to the stack goes through; after a refresh, since that is when the
|
||||
// contents of a list change; and from whoever tells a context that its content has
|
||||
// gained or lost something to select.
|
||||
func (self *ContextMgr) updateSelectionHighlights() {
|
||||
func (self *ContextMgr) UpdateSelectionHighlights() {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
@@ -399,3 +399,14 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
|
||||
|
||||
panic("context not in stack")
|
||||
}
|
||||
|
||||
// IsInStack reports whether the given context is on the stack at all, for callers
|
||||
// that can't otherwise know and would make NextInStack panic.
|
||||
func (self *ContextMgr) IsInStack(c types.Context) bool {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
|
||||
return other.GetKey() == c.GetKey()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ type BaseContext struct {
|
||||
windowName string
|
||||
onGetOptionsMap func() map[string]string
|
||||
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onClickFocusedMainViewFn onClickFocusedMainViewFn
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
focusedMainViewDiffSource types.FocusedMainViewDiffSource
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
@@ -34,9 +34,8 @@ type BaseContext struct {
|
||||
}
|
||||
|
||||
type (
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
)
|
||||
|
||||
var _ types.IBaseContext = &BaseContext{}
|
||||
@@ -122,6 +121,14 @@ func (self *BaseContext) HasSelectableContent() bool {
|
||||
return self.hasSelectableContent
|
||||
}
|
||||
|
||||
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
|
||||
// list length either: the main panes, which can only tell by reading the diff they
|
||||
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
|
||||
// ContextMgr.UpdateSelectionHighlights).
|
||||
func (self *BaseContext) SetHasSelectableContent(value bool) {
|
||||
self.hasSelectableContent = value
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetKey() types.ContextKey {
|
||||
return self.key
|
||||
}
|
||||
@@ -153,7 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.onClickFocusedMainViewFn = nil
|
||||
self.focusedMainViewDiffSource = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -175,12 +182,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
|
||||
if fn != nil {
|
||||
if self.onClickFocusedMainViewFn != nil {
|
||||
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
|
||||
func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
|
||||
if source != nil {
|
||||
if self.focusedMainViewDiffSource != nil {
|
||||
panic("only one controller is allowed to set the focused main view diff source")
|
||||
}
|
||||
self.onClickFocusedMainViewFn = fn
|
||||
self.focusedMainViewDiffSource = source
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,8 +199,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
|
||||
return self.onClickFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
|
||||
return self.onClickFocusedMainViewFn
|
||||
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self.focusedMainViewDiffSource
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
|
||||
|
||||
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
@@ -80,10 +85,16 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
|
||||
}
|
||||
|
||||
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
|
||||
if refs := self.GetRefRange(); refs != nil {
|
||||
return refs.From.ParentRefName(), refs.To.RefName()
|
||||
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
|
||||
}
|
||||
|
||||
// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a
|
||||
// range runs from the parent of its first ref to its last, a single ref from its own
|
||||
// parent to itself.
|
||||
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
|
||||
if refRange != nil {
|
||||
return refRange.From.ParentRefName(), refRange.To.RefName()
|
||||
}
|
||||
ref := self.GetRef()
|
||||
return ref.ParentRefName(), ref.RefName()
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -8,9 +8,62 @@ import (
|
||||
type MainContext struct {
|
||||
*SimpleContext
|
||||
*SearchTrait
|
||||
|
||||
diffSelect types.DiffSelectState
|
||||
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
|
||||
// drag that follows can anchor its range there. The click may have selected a whole
|
||||
// hunk, whose range anchor is the block's far end, so the clicked line can't be
|
||||
// read back from the view.
|
||||
dragAnchorViewLine int
|
||||
// selectableContentRenderKey names the render whose content HasSelectableContent
|
||||
// was worked out from. What there is to select is a property of the content, so an
|
||||
// answer about the content of another render says nothing about this one.
|
||||
selectableContentRenderKey string
|
||||
}
|
||||
|
||||
var _ types.ISearchableContext = (*MainContext)(nil)
|
||||
var (
|
||||
_ types.ISearchableContext = (*MainContext)(nil)
|
||||
_ types.DiffPaneContext = (*MainContext)(nil)
|
||||
)
|
||||
|
||||
// DiffSelectState returns the focused main view's selection mode state, for the
|
||||
// controllers to read and mutate directly.
|
||||
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
|
||||
return &self.diffSelect
|
||||
}
|
||||
|
||||
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
|
||||
// line, no range — for whenever it is established from scratch rather than moved. The
|
||||
// view's range anchor is cleared too, so the next render highlights the cursor line
|
||||
// only.
|
||||
func (self *MainContext) ResetDiffSelectMode() {
|
||||
self.diffSelect.Mode = types.DiffSelectModeLine
|
||||
self.diffSelect.RangeIsSticky = false
|
||||
self.diffSelect.UserEnabledHunkMode = false
|
||||
self.GetView().CancelRangeSelect()
|
||||
}
|
||||
|
||||
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag
|
||||
// that follows can anchor its range there (see dragAnchorViewLine).
|
||||
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
|
||||
self.dragAnchorViewLine = viewLine
|
||||
}
|
||||
|
||||
// DragAnchorViewLine returns the view line the last mouse-down landed on.
|
||||
func (self *MainContext) DragAnchorViewLine() int {
|
||||
return self.dragAnchorViewLine
|
||||
}
|
||||
|
||||
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
|
||||
// selectableContentRenderKey).
|
||||
func (self *MainContext) SelectableContentRenderKey() string {
|
||||
return self.selectableContentRenderKey
|
||||
}
|
||||
|
||||
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
|
||||
func (self *MainContext) SetSelectableContentRenderKey(key string) {
|
||||
self.selectableContentRenderKey = key
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
@@ -21,12 +74,11 @@ func NewMainContext(
|
||||
ctx := &MainContext{
|
||||
SimpleContext: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HasSelectableContent: false,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
}
|
||||
@@ -38,5 +90,8 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
|
||||
return nil
|
||||
}
|
||||
|
||||
// When selecting a search result, collapse a range selection (whether sticky or not)
|
||||
// or a hunk selection to just the matching line.
|
||||
func (self *MainContext) OnSearchSelect(int) {
|
||||
self.ResetDiffSelectMode()
|
||||
}
|
||||
|
||||
@@ -14,10 +14,15 @@ type ReflogCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Commit { return c.Model().FilteredReflogCommits },
|
||||
|
||||
@@ -12,10 +12,15 @@ type StashContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.DiffMainViewContext = (*StashContext)(nil)
|
||||
)
|
||||
|
||||
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewStashContext(
|
||||
c *ContextCommon,
|
||||
) *StashContext {
|
||||
|
||||
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
|
||||
)
|
||||
|
||||
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeStaging
|
||||
}
|
||||
|
||||
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
viewModel := filetree.NewFileTreeViewModel(
|
||||
func() []*models.File { return c.Model().Files },
|
||||
|
||||
@@ -67,7 +67,8 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
worktreeHelper,
|
||||
searchHelper,
|
||||
)
|
||||
diffHelper := helpers.NewDiffHelper(helperCommon)
|
||||
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
|
||||
diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper)
|
||||
cherryPickHelper := helpers.NewCherryPickHelper(
|
||||
helperCommon,
|
||||
rebaseHelper,
|
||||
@@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
|
||||
Snake: helpers.NewSnakeHelper(helperCommon),
|
||||
Diff: diffHelper,
|
||||
DiffLine: diffLineHelper,
|
||||
Repos: reposHelper,
|
||||
RecordDirectory: recordDirectoryHelper,
|
||||
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
|
||||
@@ -176,7 +178,6 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
contextLinesController := controllers.NewContextLinesController(common)
|
||||
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
|
||||
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
|
||||
|
||||
branchesController := controllers.NewBranchesController(common)
|
||||
gitFlowController := controllers.NewGitFlowController(common)
|
||||
@@ -313,13 +314,11 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
controllers.AttachControllers(gui.State.Contexts.Normal,
|
||||
mainViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
|
||||
secondaryViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Files,
|
||||
|
||||
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
|
||||
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
|
||||
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
|
||||
context.AddOnClickFn(controller.GetOnClick())
|
||||
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
|
||||
context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
|
||||
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
|
||||
context.AddOnFocusFn(controller.GetOnFocus())
|
||||
context.AddOnFocusLostFn(controller.GetOnFocusLost())
|
||||
|
||||
@@ -19,11 +19,11 @@ func (self *baseController) GetOnDoubleClick() func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
|
||||
func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -606,16 +618,6 @@ func (self *CommitFilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
|
||||
return diffPathsForNode(
|
||||
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
|
||||
|
||||
@@ -83,6 +83,11 @@ func (self *ContextLinesController) applyChange() error {
|
||||
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
|
||||
default:
|
||||
// The diff is about to be rendered again with more or less context around
|
||||
// each change, which reads as the lines you were looking at moving up or down
|
||||
// the view; keep them where they are instead.
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
|
||||
currentContext.HandleRenderToMain()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -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,58 +370,50 @@ 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})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// if we are dealing with a status for which there is no key in this map,
|
||||
// then we won't optimistically render: we'll just let `git status` tell
|
||||
// us what the new status is.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type DiffLineHelper struct {
|
||||
c *HelperCommon
|
||||
}
|
||||
|
||||
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
|
||||
return &DiffLineHelper{c: c}
|
||||
}
|
||||
|
||||
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
|
||||
// of the diff row at the given (wrapped) view line of the given view. It is the
|
||||
// seam every consumer of a diff row goes through, so that how we recover that
|
||||
// identity can change without them noticing.
|
||||
//
|
||||
// There are two ways, and which one is used is settled for the rendering as a
|
||||
// whole (see renderingStatesDiffLines). A diff renderer that speaks the OSC 1717
|
||||
// protocol states the identity of each line it renders. That is the only way to
|
||||
// recover it from a rendering that doesn't look like a diff any more (columns, or
|
||||
// +/- markers replaced by colour), and a row such a renderer says nothing about
|
||||
// has no identity. Otherwise we parse the view's contents as a unified diff; this
|
||||
// works for the renderings that keep a diff's structure (no renderer, `git diff
|
||||
// --color`, a renderer that only colorizes) and fails for the rest.
|
||||
//
|
||||
// ok is false when the row's identity can't be recovered, in which case the
|
||||
// caller must not act on the line at all.
|
||||
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view
|
||||
// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't
|
||||
// settle for the line the row leads with: an end of a selection covers its whole
|
||||
// row, so where a rendering puts a modification's two halves side by side it covers
|
||||
// both of them. ok is false when the row's identity can't be recovered at all.
|
||||
func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
view *gocui.View, viewLineIdx int,
|
||||
) ([]types.DiffLineInfo, bool) {
|
||||
// The cursor and clicks land on a view line, which counts wrapped segments;
|
||||
// the contents are indexed by unwrapped buffer line.
|
||||
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
contents := view.DiffLineContents()
|
||||
if bufferLineIdx >= len(contents) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if renderingStatesDiffLines(contents) {
|
||||
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
|
||||
return identities, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
|
||||
// records state, left to right. A row can carry more than one record, when the
|
||||
// rendering puts two diff lines on it (a side-by-side row shows a deletion and the
|
||||
// addition replacing it). Which of them a reader is after depends on the reader: the
|
||||
// one the row leads with is the row's own identity (see GetDiffLineInfo and
|
||||
// resolveDiffLines), while a reader looking for a particular line has to consider
|
||||
// them all, since which of a modification's two halves leads a row is up to the
|
||||
// rendering.
|
||||
func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo {
|
||||
return self.diffLineInfos(parseDiffLineRecords(metadata))
|
||||
}
|
||||
|
||||
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
|
||||
// be recovered at all — the element of the table resolveDiffLines produces.
|
||||
type resolvedDiffLine struct {
|
||||
info types.DiffLineInfo
|
||||
ok bool
|
||||
}
|
||||
|
||||
// resolveDiffLines recovers the identity of every row of a rendered diff in one
|
||||
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
|
||||
// whole-buffer scans (which change lines are where, which file each row belongs
|
||||
// to). A row's identity is the line it leads with, of those resolveDiffLineIdentities
|
||||
// finds on it.
|
||||
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
|
||||
resolved := make([]resolvedDiffLine, len(contents))
|
||||
for i, identities := range self.resolveDiffLineIdentities(contents) {
|
||||
if len(identities) > 0 {
|
||||
resolved[i] = resolvedDiffLine{identities[0], true}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// resolveDiffLineIdentities recovers every diff line each row of a rendered diff
|
||||
// shows, in one pass, indexed 1:1 with contents. It reads the rendering the way
|
||||
// GetDiffLineInfo does, by the renderer's records or by parsing it as a unified diff
|
||||
// (see parseDiffLineIdentities), and is the form of the batch resolver for the
|
||||
// readers that can't settle for the line a row leads with: looking for a remembered
|
||||
// line in a new rendering has to consider both halves of a modification, since a
|
||||
// side-by-side row leads with the deletion whose addition was what got remembered
|
||||
// under a unified one.
|
||||
func (self *DiffLineHelper) resolveDiffLineIdentities(contents []gocui.DiffLineContent) [][]types.DiffLineInfo {
|
||||
identities := make([][]types.DiffLineInfo, len(contents))
|
||||
for i, parsed := range parseDiffLineIdentities(contents) {
|
||||
if len(parsed) > 0 {
|
||||
identities[i] = self.diffLineInfos(parsed)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// diffLineInfo turns a parser's result into the absolute-path identity consumers
|
||||
// work with. The path arrives repo-relative from the diff header, but a renderer
|
||||
// states it however it likes, absolute paths included.
|
||||
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
|
||||
return diffLineInfoIn(self.c.Git().RepoPaths.WorktreePath(), parsed)
|
||||
}
|
||||
|
||||
// diffLineInfos is diffLineInfo over every line of a row.
|
||||
func (self *DiffLineHelper) diffLineInfos(parsed []parsedDiffLine) []types.DiffLineInfo {
|
||||
infos := make([]types.DiffLineInfo, len(parsed))
|
||||
for i, line := range parsed {
|
||||
infos[i] = self.diffLineInfo(line)
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// diffLineInfoIn is diffLineInfo against a given worktree, for the callers that can't
|
||||
// ask which repo we are in where they run: a repo switch replaces it, so only the UI
|
||||
// thread may read it.
|
||||
func diffLineInfoIn(worktreePath string, parsed parsedDiffLine) types.DiffLineInfo {
|
||||
path := parsed.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(worktreePath, path)
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
Path: path,
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
|
||||
// unified diff.
|
||||
const diffFilePrefix = "diff --git "
|
||||
|
||||
// submodulePrefix opens the line a submodule's section of a diff starts with, which
|
||||
// stands in for the "diff --git" header a file of the repo gets.
|
||||
const submodulePrefix = "Submodule "
|
||||
|
||||
// submoduleSectionPattern matches that line and captures the submodule's path. git
|
||||
// writes one of two kinds: the commit the submodule is checked out at has moved
|
||||
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
|
||||
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
|
||||
// place of the colon where the two commits can't both be read), or its working tree
|
||||
// is dirty ("Submodule sub contains untracked content").
|
||||
//
|
||||
// The path is captured greedily: git writes it unquoted, so a path that itself ends
|
||||
// in something reading like a range of commits is told apart by taking the last such
|
||||
// range on the line.
|
||||
var submoduleSectionPattern = regexp.MustCompile(
|
||||
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
|
||||
|
||||
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
|
||||
// Path is the path as the diff header spells it, i.e. relative to the repo root;
|
||||
// the caller turns it into the absolute path of types.DiffLineInfo.
|
||||
type parsedDiffLine struct {
|
||||
Path string
|
||||
Type types.DiffLineType
|
||||
NewLine int
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// bufferLineParse is the parser's result for one buffer line: the recovered
|
||||
// identity, and whether the line could be resolved at all (false for a line in
|
||||
// an unparseable section, or outside any file section).
|
||||
type bufferLineParse struct {
|
||||
parsed parsedDiffLine
|
||||
ok bool
|
||||
}
|
||||
|
||||
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
|
||||
// parsing the view's decolorized contents.
|
||||
//
|
||||
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
|
||||
// resolve. A commit's diff spans several files, so we isolate the file section
|
||||
// containing targetIdx and parse just that one (see parseFileSection). Use this
|
||||
// for a single line, e.g. the one under the cursor; to resolve every line of a
|
||||
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
|
||||
//
|
||||
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
|
||||
// because the diff renderer restructured it, so that the caller can fall back.
|
||||
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
|
||||
if targetIdx < 0 || targetIdx >= len(bufferLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
start, end := fileSectionBounds(bufferLines, targetIdx)
|
||||
if start == -1 {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start]
|
||||
return r.parsed, r.ok
|
||||
}
|
||||
|
||||
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
|
||||
// diff buffer in one pass, parsing each file section exactly once. It is the
|
||||
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
|
||||
// resolving line by line would re-parse a section once per line of it — O(n²) on
|
||||
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
|
||||
// with bufferLines; a line in an unparseable section, or above the first one, is
|
||||
// left ok=false.
|
||||
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(bufferLines))
|
||||
for i := 0; i < len(bufferLines); {
|
||||
if !startsFileSection(bufferLines[i]) {
|
||||
i++ // in no file section; leave it unresolved
|
||||
continue
|
||||
}
|
||||
end := fileSectionEnd(bufferLines, i)
|
||||
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
|
||||
i = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// diffLineTexts extracts the text of each rendered row — the material the buffer
|
||||
// parser works on.
|
||||
func diffLineTexts(contents []gocui.DiffLineContent) []string {
|
||||
texts := make([]string, len(contents))
|
||||
for i, content := range contents {
|
||||
texts[i] = content.Text
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// renderingStatesDiffLines reports whether the renderer stated, for at least one row
|
||||
// of the rendering, which diff line it shows. The version-only record a renderer
|
||||
// announces the protocol with names no line, and doesn't count.
|
||||
//
|
||||
// The answer settles how the whole rendering is read. A renderer that states its
|
||||
// lines lays the diff out as it likes, so its text is no unified diff and must not be
|
||||
// parsed as one, not even for the rows it says nothing about. Such a row can read
|
||||
// like a diff header when the file being diffed is itself a diff; the parser would
|
||||
// take that for the start of a file section and place every untagged row below it
|
||||
// in a file the diff doesn't have. So the rows a renderer leaves untagged (dividers,
|
||||
// padding) have no identity, as the protocol has it. A rendering without any record
|
||||
// is a diff that describes itself, and is parsed as one.
|
||||
func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
|
||||
return slices.ContainsFunc(contents, func(content gocui.DiffLineContent) bool {
|
||||
return slices.ContainsFunc(content.Metadata, func(record string) bool {
|
||||
_, ok := parseDiffLineMetadata(record)
|
||||
return ok
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// parseDiffLineRecords parses the records a row carries, left to right, leaving out
|
||||
// the ones we don't understand. A row carries more than one when the rendering puts
|
||||
// two diff lines on it, as a side-by-side row does with a deletion and the addition
|
||||
// replacing it.
|
||||
func parseDiffLineRecords(metadata []string) []parsedDiffLine {
|
||||
parsed := make([]parsedDiffLine, 0, len(metadata))
|
||||
for _, record := range metadata {
|
||||
if line, ok := parseDiffLineMetadata(record); ok {
|
||||
parsed = append(parsed, line)
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// parseDiffLineIdentities recovers, for every row of a rendering, the diff lines it
|
||||
// shows, indexed 1:1 with contents; a row that shows none we can place gets an empty
|
||||
// entry. The rendering is read the way renderingStatesDiffLines settles: by the
|
||||
// renderer's records, every one a row carries, or else by parsing the rendering as a
|
||||
// unified diff, where each row shows one line. Each file's section is parsed once;
|
||||
// resolving row by row would re-run that parse once per row, O(n²) on a large
|
||||
// single-file diff.
|
||||
func parseDiffLineIdentities(contents []gocui.DiffLineContent) [][]parsedDiffLine {
|
||||
identities := make([][]parsedDiffLine, len(contents))
|
||||
if renderingStatesDiffLines(contents) {
|
||||
for i, content := range contents {
|
||||
if parsed := parseDiffLineRecords(content.Metadata); len(parsed) > 0 {
|
||||
identities[i] = parsed
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
|
||||
if parsed.ok {
|
||||
identities[i] = []parsedDiffLine{parsed.parsed}
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// fileSectionBounds returns the half-open range [start, end) of the file section
|
||||
// containing targetIdx: the nearest line starting a section at or above it, up to
|
||||
// where that section ends. start is -1 when targetIdx is above the first file
|
||||
// section, or below the end of the last one that begins above it.
|
||||
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
|
||||
for start = targetIdx; start >= 0; start-- {
|
||||
if !startsFileSection(bufferLines[start]) {
|
||||
continue
|
||||
}
|
||||
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
|
||||
return start, end
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// fileSectionEnd returns the line the file section beginning at start ends before.
|
||||
//
|
||||
// A file's section runs to the next one, since every line between them is part of
|
||||
// its diff. A submodule's runs only as far as what git writes for it — the line
|
||||
// naming it, and the log of the commits it moved over — because the lines after
|
||||
// that need not belong to any section at all. A diff renderer's output has no
|
||||
// "diff --git" line to stop at, and the rows it puts between one file and the next
|
||||
// belong to neither.
|
||||
func fileSectionEnd(bufferLines []string, start int) int {
|
||||
if submodulePath(bufferLines[start]) != "" {
|
||||
end := start + 1
|
||||
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
for i := start + 1; i < len(bufferLines); i++ {
|
||||
if startsFileSection(bufferLines[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(bufferLines)
|
||||
}
|
||||
|
||||
// startsFileSection reports whether the line opens a section of a diff: git's header
|
||||
// for a file of the repo, or the line a submodule's section begins with.
|
||||
func startsFileSection(line string) bool {
|
||||
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
|
||||
}
|
||||
|
||||
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
|
||||
// a submodule's header, which it writes as two spaces, the direction the commit was
|
||||
// moved in, and the commit's subject.
|
||||
func isSubmoduleLogLine(line string) bool {
|
||||
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
|
||||
}
|
||||
|
||||
// submodulePath returns the submodule whose section the given line opens, and "" for
|
||||
// every other line.
|
||||
//
|
||||
// A submodule gets no "diff --git" header and no hunks: git states which commits it
|
||||
// moved between and lists them, so that one line is all there is to take the path
|
||||
// from. The prefix is tested first so that the pattern is run over next to no lines
|
||||
// of a diff.
|
||||
func submodulePath(line string) string {
|
||||
if !strings.HasPrefix(line, submodulePrefix) {
|
||||
return ""
|
||||
}
|
||||
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFileSection parses one file's diff section (fileLines, starting at the line
|
||||
// that opens it) a single time and returns the identity of each of its
|
||||
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
|
||||
// section's buffer lines, so the type and the old/new line numbers fall out of
|
||||
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
|
||||
// it comes out as a header of the submodule, which is what they are: what git states
|
||||
// there is which commits it moved between, not lines of a file.
|
||||
//
|
||||
// Every line is left ok=false when the section has no
|
||||
// recoverable path or isn't a well-formed unified diff — the rendering
|
||||
// restructured it, and acting on a mis-parse would land us on the wrong line, so
|
||||
// the caller should fall back.
|
||||
//
|
||||
// endsTheBuffer says the section runs to the end of what we were given. That is
|
||||
// where a diff we have only part of breaks off. A long one is read a screenful
|
||||
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
|
||||
// than its header declares until the reading is done. Insisting on the whole
|
||||
// hunk there would leave every line of the file unresolved while the diff is the
|
||||
// one on screen, so a section in that position is held to what has arrived.
|
||||
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(fileLines))
|
||||
|
||||
relPath := pathFromDiffHeader(fileLines)
|
||||
if relPath == "" {
|
||||
return result
|
||||
}
|
||||
p := patch.Parse(strings.Join(fileLines, "\n"))
|
||||
isWellFormed := p.IsWellFormed
|
||||
if endsTheBuffer {
|
||||
isWellFormed = p.IsWellFormedSoFar
|
||||
}
|
||||
if !isWellFormed() {
|
||||
return result
|
||||
}
|
||||
patchLines := p.Lines()
|
||||
for i := range fileLines {
|
||||
if i >= len(patchLines) {
|
||||
break
|
||||
}
|
||||
parsed := parsedDiffLine{
|
||||
Path: relPath,
|
||||
Type: diffLineTypeForKind(patchLines[i].Kind),
|
||||
NewLine: p.LineNumberOfLine(i),
|
||||
}
|
||||
if parsed.Type == types.DiffLineDeleted {
|
||||
parsed.OldLine = p.OldLineNumberOfLine(i)
|
||||
}
|
||||
result[i] = bufferLineParse{parsed, true}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
|
||||
switch kind {
|
||||
case patch.PATCH_HEADER:
|
||||
return types.DiffLineFileHeader
|
||||
case patch.HUNK_HEADER:
|
||||
return types.DiffLineHunkHeader
|
||||
case patch.ADDITION:
|
||||
return types.DiffLineAdded
|
||||
case patch.DELETION:
|
||||
return types.DiffLineDeleted
|
||||
case patch.CONTEXT:
|
||||
return types.DiffLineContext
|
||||
default:
|
||||
return types.DiffLineOther
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffHeader extracts the new-file path of a single diff section. A
|
||||
// submodule's section states its path in the line it opens with. For a file of the
|
||||
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
|
||||
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
|
||||
// there are no such lines at all (a pure rename, which has no hunks).
|
||||
func pathFromDiffHeader(fileLines []string) string {
|
||||
if path := submodulePath(fileLines[0]); path != "" {
|
||||
return path
|
||||
}
|
||||
|
||||
var oldPath, newPath string
|
||||
for _, line := range fileLines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break // past the header
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
|
||||
}
|
||||
}
|
||||
|
||||
if newPath != "" && newPath != "/dev/null" {
|
||||
return newPath
|
||||
}
|
||||
if oldPath != "" && oldPath != "/dev/null" {
|
||||
return oldPath
|
||||
}
|
||||
return pathFromDiffGitLine(fileLines[0])
|
||||
}
|
||||
|
||||
// pathFromDiffHeaderField decodes one path field of a diff header — the part
|
||||
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
|
||||
// into the repo-relative path it names.
|
||||
//
|
||||
// git spells such a field in three ways: plain; terminated by a tab, when the
|
||||
// path contains a space; or C-quoted as a whole, when the path contains
|
||||
// characters git won't print raw — which, with core.quotePath enabled (the
|
||||
// default), includes every non-ASCII byte, so `café` arrives as
|
||||
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
|
||||
// so strconv decodes it for us.
|
||||
//
|
||||
// Returns "" for a quoted field we can't decode: better to resolve nothing than
|
||||
// to point a consumer at a path that doesn't exist.
|
||||
func pathFromDiffHeaderField(field string) string {
|
||||
field = strings.TrimSuffix(field, "\t")
|
||||
|
||||
if strings.HasPrefix(field, `"`) {
|
||||
unquoted, err := strconv.Unquote(field)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
field = unquoted
|
||||
}
|
||||
|
||||
return stripDiffPathPrefix(field)
|
||||
}
|
||||
|
||||
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
|
||||
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
|
||||
// so they are always there.
|
||||
func stripDiffPathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
return path[2:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
|
||||
// diff renderer states which line of which file it is rendering. The v1 payload
|
||||
// is positional and ';'-delimited:
|
||||
//
|
||||
// version;type;new-line;old-line;file
|
||||
//
|
||||
// The file comes last so that it may itself contain a ';'. The old-file line is
|
||||
// empty unless the line is a deletion, the only kind that needs it, and the
|
||||
// new-file line is empty on a file header, the one kind that has no line.
|
||||
//
|
||||
// ok is false for a payload of an unknown version or shape, so that the caller
|
||||
// can fall back to reading the rendered text.
|
||||
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
|
||||
fields := strings.SplitN(payload, ";", 5)
|
||||
if len(fields) < 5 || fields[0] != "1" {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
lineType, ok := diffLineTypeFromMetadata(fields[1])
|
||||
if !ok {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
newLine := 0
|
||||
if fields[2] != "" {
|
||||
var err error
|
||||
if newLine, err = strconv.Atoi(fields[2]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
} else if lineType != types.DiffLineFileHeader {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
oldLine := 0
|
||||
if fields[3] != "" {
|
||||
var err error
|
||||
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
}
|
||||
|
||||
return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
|
||||
}
|
||||
|
||||
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
|
||||
switch typeField {
|
||||
case "c":
|
||||
return types.DiffLineContext, true
|
||||
case "a":
|
||||
return types.DiffLineAdded, true
|
||||
case "d":
|
||||
return types.DiffLineDeleted, true
|
||||
case "f":
|
||||
return types.DiffLineFileHeader, true
|
||||
case "h":
|
||||
return types.DiffLineHunkHeader, true
|
||||
default:
|
||||
return types.DiffLineOther, false
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
|
||||
// line, where the two paths are separated by a space and either may be quoted.
|
||||
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
|
||||
// are unambiguous and we only get here when they are absent.
|
||||
func pathFromDiffGitLine(line string) string {
|
||||
rest := strings.TrimPrefix(line, diffFilePrefix)
|
||||
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
|
||||
return pathFromDiffHeaderField(rest[idx+1:])
|
||||
}
|
||||
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
|
||||
return pathFromDiffHeaderField(rest[idx+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
|
||||
// two consecutive deletions (grape, pear) that share a new-file line number;
|
||||
// file2 has two consecutive additions.
|
||||
const twoFileDiff = `diff --git a/file1.go b/file1.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.go
|
||||
+++ b/file1.go
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
diff --git a/dir/file2.go b/dir/file2.go
|
||||
index 3333333..4444444 100644
|
||||
--- a/dir/file2.go
|
||||
+++ b/dir/file2.go
|
||||
@@ -10,2 +9,4 @@ func foo() {
|
||||
ctx
|
||||
+added1
|
||||
+added2
|
||||
ctx2`
|
||||
|
||||
func TestParseDiffLineFromBuffer(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
targetIdx int
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
|
||||
{"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
|
||||
{"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
// The two deletions share new-file line 2 but have distinct old-file lines.
|
||||
{"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// The second file: its path comes from the second "diff --git" section,
|
||||
// and its additions get distinct new-file line numbers.
|
||||
{"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
|
||||
{"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
|
||||
{"out of range", 999, parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferRename(t *testing.T) {
|
||||
// A rename with no content change has no hunks and no +++/--- lines, so the
|
||||
// path has to come from the "diff --git" line; a rename with a content
|
||||
// change has them, and they carry the new path.
|
||||
pureRename := strings.Split(`diff --git a/old.go b/new.go
|
||||
similarity index 100%
|
||||
rename from old.go
|
||||
rename to new.go`, "\n")
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(pureRename, 2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
|
||||
|
||||
renameWithModification := strings.Split(`diff --git a/old.go b/new.go
|
||||
similarity index 62%
|
||||
rename from old.go
|
||||
rename to new.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/old.go
|
||||
+++ b/new.go
|
||||
@@ -1,2 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
+kiwi`, "\n")
|
||||
|
||||
result, ok = parseDiffLineFromBuffer(renameWithModification, 10)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferDeletedFile(t *testing.T) {
|
||||
// The new path is /dev/null, so the identity comes from the old path.
|
||||
deletedFile := strings.Split(`diff --git a/gone.go b/gone.go
|
||||
deleted file mode 100644
|
||||
index 1111111..0000000
|
||||
--- a/gone.go
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-apple
|
||||
-grape`, "\n")
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(deletedFile, 7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmodule(t *testing.T) {
|
||||
// A submodule has no "diff --git" header and no hunks: git opens its section with
|
||||
// the commits it moved between and lists them below. So the section ends the one
|
||||
// above it, and every row of it belongs to the submodule as a whole.
|
||||
withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -1,2 +1,3 @@
|
||||
hello
|
||||
world
|
||||
+world
|
||||
Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing`, "\n")
|
||||
|
||||
for _, targetIdx := range []int{8, 9} {
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t,
|
||||
parsedDiffLine{Path: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1},
|
||||
result, "line %d", targetIdx)
|
||||
}
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(withSubmodule, 7)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) {
|
||||
// A diff renderer passes the lines git writes for a submodule through as they
|
||||
// are, while printing nothing below them that a section could end at. The
|
||||
// section has to end where what git writes for the submodule ends all the same:
|
||||
// the rows below belong to the other files of the diff, and can only be placed
|
||||
// by the records the renderer states for them.
|
||||
rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921:
|
||||
> bump the thing
|
||||
|
||||
products/a.txt
|
||||
|
||||
one
|
||||
two`, "\n")
|
||||
|
||||
all := parseAllDiffLinesFromBuffer(rendered)
|
||||
assert.Equal(t, "modules/xyz", all[0].parsed.Path)
|
||||
assert.Equal(t, "modules/xyz", all[1].parsed.Path)
|
||||
for i := 2; i < len(rendered); i++ {
|
||||
assert.False(t, all[i].ok, "line %d: %q", i, rendered[i])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmodulePath(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
line string
|
||||
expected string
|
||||
}{
|
||||
{"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"},
|
||||
{"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"},
|
||||
{"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"},
|
||||
{"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"},
|
||||
{"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"},
|
||||
{"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"},
|
||||
{"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"},
|
||||
{"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"},
|
||||
// git writes the path unquoted, so one with a space in it, or one ending in
|
||||
// something that reads like a range of commits, is told apart by matching the
|
||||
// last range on the line.
|
||||
{"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"},
|
||||
{"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"},
|
||||
// A line of a file that reads like one of these is indented by the column the
|
||||
// diff states the line's side in, so it cannot be mistaken for one.
|
||||
{"a line of a file", " Submodule modules/xyz contains modified content", ""},
|
||||
{"something else entirely", "Submodule support was added", ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, submodulePath(s.line))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
|
||||
// A rendering with no "diff --git" line can't be parsed, so the caller falls
|
||||
// back rather than acting on the line.
|
||||
bufferLines := []string{"some", "lines", "that", "are not a diff"}
|
||||
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
|
||||
// A diff renderer that moves the line numbers into a gutter keeps the diff
|
||||
// and hunk headers but pushes the +/- markers off the start of each body
|
||||
// line, so every line reads as context. The body no longer matches the hunk
|
||||
// header, so we refuse to parse rather than return a confident mis-parse.
|
||||
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.txt
|
||||
+++ b/file1.txt
|
||||
@@ -1,5 +1,3 @@
|
||||
1 ⋮ 1 │ apple
|
||||
2 ⋮ │-grape
|
||||
3 ⋮ │-pear
|
||||
4 ⋮ 2 │ lemon
|
||||
5 ⋮ 3 │ mango`, "\n")
|
||||
|
||||
_, ok := parseDiffLineFromBuffer(mangled, 6)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferReadInPart(t *testing.T) {
|
||||
lines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
// A long diff is read a screenful at a time, so the buffer breaks off part way
|
||||
// through a hunk. The lines that did arrive are resolved all the same, since
|
||||
// holding out for the whole hunk would leave the diff on screen with nothing to
|
||||
// act on.
|
||||
cutShort := lines[:len(lines)-1]
|
||||
result, ok := parseDiffLineFromBuffer(cutShort, 15)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result)
|
||||
|
||||
// Only the section the buffer breaks off in is read that way. One that another
|
||||
// section follows is all there, so a hunk short of what its header declares means
|
||||
// the rendering restructured the diff, and none of it is resolved.
|
||||
shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...)
|
||||
_, ok = parseDiffLineFromBuffer(shortFirstSection, 5)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestPathFromDiffHeaderField(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field string
|
||||
expected string
|
||||
}{
|
||||
{"new side", "b/file.go", "file.go"},
|
||||
{"old side", "a/file.go", "file.go"},
|
||||
{"a missing file", "/dev/null", "/dev/null"},
|
||||
// git terminates the field with a tab when the path has a space in it.
|
||||
{"path with a space", "b/with space.go\t", "with space.go"},
|
||||
// With core.quotePath enabled (the default) every non-ASCII byte is
|
||||
// escaped, and the field is quoted as a whole, prefix included.
|
||||
{"non-ASCII path", `"b/caf\303\251.go"`, "café.go"},
|
||||
{"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"},
|
||||
{"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`},
|
||||
{"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`},
|
||||
{"path with a tab", `"b/tab\there.go"`, "tab\there.go"},
|
||||
{"undecodable", `"b/unterminated`, ""},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferQuotedPath(t *testing.T) {
|
||||
// A rename of a file whose name needs quoting, with a content change: the
|
||||
// path is quoted on the "diff --git" line and on both of the +++/--- lines.
|
||||
renamed := []string{
|
||||
`diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`,
|
||||
"similarity index 62%",
|
||||
`rename from "caf\303\251 old.go"`,
|
||||
`rename to "caf\303\251 new.go"`,
|
||||
"index 1111111..2222222 100644",
|
||||
"--- \"a/caf\\303\\251 old.go\"\t",
|
||||
"+++ \"b/caf\\303\\251 new.go\"\t",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" apple",
|
||||
"-grape",
|
||||
"+kiwi",
|
||||
}
|
||||
|
||||
result, ok := parseDiffLineFromBuffer(renamed, 10)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
|
||||
|
||||
// The same rename without a content change has no +++/--- lines, so the path
|
||||
// comes from the "diff --git" line, where both paths are quoted.
|
||||
result, ok = parseDiffLineFromBuffer(renamed[:4], 2)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
|
||||
}
|
||||
|
||||
func TestParseAllDiffLinesFromBuffer(t *testing.T) {
|
||||
// Some decoration above the diff, which belongs to no file section: a commit
|
||||
// message and a diffstat, as `git show` renders them.
|
||||
bufferLines := append(
|
||||
[]string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""},
|
||||
strings.Split(twoFileDiff, "\n")...,
|
||||
)
|
||||
|
||||
all := parseAllDiffLinesFromBuffer(bufferLines)
|
||||
|
||||
// The batch parse resolves each file section once, and has to agree with
|
||||
// resolving the lines one at a time.
|
||||
assert.Len(t, all, len(bufferLines))
|
||||
for i := range bufferLines {
|
||||
parsed, ok := parseDiffLineFromBuffer(bufferLines, i)
|
||||
assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i])
|
||||
}
|
||||
|
||||
// The lines above the first file section are left unresolved.
|
||||
for i := range 6 {
|
||||
assert.False(t, all[i].ok)
|
||||
}
|
||||
assert.True(t, all[6].ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineMetadata(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
payload string
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
{"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
|
||||
// A deletion carries both numbers; two consecutive deletions share the
|
||||
// new-file line and differ only in the old-file one.
|
||||
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// A whole-file deletion has new-file position 0 and the old path.
|
||||
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
|
||||
// The path is the last field, so a ';' within it survives.
|
||||
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
|
||||
// A renderer may state the path absolutely; the parser keeps it verbatim
|
||||
// and leaves resolving it to the caller.
|
||||
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
|
||||
// A file header has no line number; a hunk header carries the new-file
|
||||
// line of the hunk's first line (0 for a whole-file deletion, mirroring
|
||||
// `@@ -1,N +0,0 @@`).
|
||||
{"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true},
|
||||
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
|
||||
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
|
||||
// A file header's line number is always empty, but a renderer that fills
|
||||
// it in anyway is taken at its word rather than rejected.
|
||||
{"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
|
||||
|
||||
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"too few fields", "1;c;1", parsedDiffLine{}, false},
|
||||
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
|
||||
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
|
||||
// Only a file header may omit the new-file line; on any other kind the
|
||||
// record is malformed, and rejecting it falls the row back to the diff
|
||||
// text rather than acting on a line number we don't have.
|
||||
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
|
||||
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineMetadata(s.payload)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderingStatesDiffLines(t *testing.T) {
|
||||
row := func(text string, records ...string) gocui.DiffLineContent {
|
||||
return gocui.DiffLineContent{Text: text, Metadata: records}
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
contents []gocui.DiffLineContent
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "a rendering without records is read as a diff",
|
||||
contents: []gocui.DiffLineContent{row("diff --git a/foo.txt b/foo.txt"), row("+one")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "a record on any row makes the records the source",
|
||||
contents: []gocui.DiffLineContent{row("foo.txt"), row("one", "1;c;1;;foo.txt"), row("")},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
// A renderer announces the protocol with a record that names no line; a
|
||||
// rendering with nothing but that one says nothing about its rows.
|
||||
name: "the version-only handshake record doesn't count",
|
||||
contents: []gocui.DiffLineContent{row("foo.txt", "1"), row("one")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "records of a version we don't understand don't count",
|
||||
contents: []gocui.DiffLineContent{row("one", "2;c;1;;foo.txt")},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "an empty rendering states nothing",
|
||||
contents: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, renderingStatesDiffLines(s.contents))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineIdentities(t *testing.T) {
|
||||
row := func(text string, records ...string) gocui.DiffLineContent {
|
||||
return gocui.DiffLineContent{Text: text, Metadata: records}
|
||||
}
|
||||
|
||||
t.Run("a rendering with records is read by them alone", func(t *testing.T) {
|
||||
// A renderer's picture of a commit that adds a test whose input is a diff. The
|
||||
// test's "diff --git" line is an added line of the test's file and is shown on
|
||||
// a row of its own, and the rows the renderer puts between hunks carry no
|
||||
// record. Parsed as a diff, that row would open a section of a file the diff
|
||||
// doesn't have and claim every untagged row below it.
|
||||
contents := []gocui.DiffLineContent{
|
||||
row("src/parser.rs", "1;f;;;src/parser.rs"),
|
||||
row(`let input = "\`, "1;c;10;;src/parser.rs"),
|
||||
row("diff --git a/img.png b/img.png", "1;a;11;;src/parser.rs"),
|
||||
row("Binary files a/img.png and b/img.png differ", "1;a;12;;src/parser.rs"),
|
||||
row(""),
|
||||
row("fn later() {}", "1;c;40;;src/parser.rs"),
|
||||
}
|
||||
|
||||
assert.Equal(t, [][]parsedDiffLine{
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineFileHeader}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 10}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 11}},
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineAdded, NewLine: 12}},
|
||||
nil,
|
||||
{{Path: "src/parser.rs", Type: types.DiffLineContext, NewLine: 40}},
|
||||
}, parseDiffLineIdentities(contents))
|
||||
})
|
||||
|
||||
t.Run("a row with two records shows both of their lines", func(t *testing.T) {
|
||||
contents := []gocui.DiffLineContent{
|
||||
row("two │ TWO", "1;d;2;2;file1", "1;a;2;;file1"),
|
||||
}
|
||||
|
||||
assert.Equal(t, [][]parsedDiffLine{
|
||||
{
|
||||
{Path: "file1", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2},
|
||||
{Path: "file1", Type: types.DiffLineAdded, NewLine: 2},
|
||||
},
|
||||
}, parseDiffLineIdentities(contents))
|
||||
})
|
||||
|
||||
t.Run("a rendering without records is parsed as a diff", func(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
contents := make([]gocui.DiffLineContent, len(bufferLines))
|
||||
for i, line := range bufferLines {
|
||||
contents[i] = row(line)
|
||||
}
|
||||
|
||||
identities := parseDiffLineIdentities(contents)
|
||||
for i, parsed := range parseAllDiffLinesFromBuffer(bufferLines) {
|
||||
assert.True(t, parsed.ok, "line %d", i)
|
||||
assert.Equal(t, []parsedDiffLine{parsed.parsed}, identities[i], "line %d", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// Reading a rendering back to the diff it came from. What a diff view shows is a diff
|
||||
// renderer's picture of a diff, and a picture is not what you want on your clipboard,
|
||||
// or in a patch — so the lines of interest are located by identity in the diff itself,
|
||||
// which the panel that rendered it hands out (types.FocusedMainViewDiffSource).
|
||||
|
||||
// PlainDiffOfSelection returns the text of the rows selected in view, in two parts.
|
||||
//
|
||||
// aboveDiff is what the selection covers above the diff, as it reads on screen; see
|
||||
// textAboveDiff. fromDiff is the diff behind the rest: per file the selection touches,
|
||||
// the run of diff lines from the first of its selected lines to the last, with the
|
||||
// files in the order the selection meets them.
|
||||
//
|
||||
// A run, rather than the matched lines alone, so that what comes out reads as a diff:
|
||||
// the lines between two selected ones come along even when the rendering didn't show
|
||||
// them (difftastic leaves out whitespace-only changes) or showed them in another order
|
||||
// (a side-by-side rendering groups the deletions of a hunk before its additions).
|
||||
//
|
||||
// Headers are selected lines like any other. A hunk header names the first line of its
|
||||
// hunk, in the rendering as in the diff, so it is looked for the way a line of the file
|
||||
// is; a file header names no line at all and a rendering may spread it over as many
|
||||
// rows as it likes, so a selection touching one of them takes the whole header.
|
||||
//
|
||||
// plainDiff fetches the diff of the given repo-relative files, and is asked only for
|
||||
// the files the selection touches, so that copying three lines of a commit's diff
|
||||
// doesn't fetch the whole of it. fromDiff is "" when no selected row could be found in
|
||||
// the diff, e.g. because the selection covers nothing but a renderer's decoration.
|
||||
func (self *DiffLineHelper) PlainDiffOfSelection(
|
||||
view *gocui.View, first int, last int, plainDiff func(paths []string) string,
|
||||
) (aboveDiff string, fromDiff string) {
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
aboveDiff = self.textAboveDiff(view, first, last)
|
||||
|
||||
// The files in the order they are shown, and per file what the selection covers of
|
||||
// its diff.
|
||||
paths := []string{}
|
||||
selection := map[string]*selectedDiffLines{}
|
||||
for _, info := range self.DiffLinesInViewRange(view, first, last) {
|
||||
// A row that is neither a line of the file nor a header — the "\ No newline at
|
||||
// end of file" marker — names nothing to look for. It comes along anyway when it
|
||||
// falls within a run.
|
||||
if info.Type == types.DiffLineOther {
|
||||
continue
|
||||
}
|
||||
selected, ok := selection[info.Path]
|
||||
if !ok {
|
||||
paths = append(paths, info.Path)
|
||||
selected = &selectedDiffLines{lines: map[patchLine]bool{}}
|
||||
selection[info.Path] = selected
|
||||
}
|
||||
if info.Type == types.DiffLineFileHeader {
|
||||
selected.header = true
|
||||
} else {
|
||||
selected.lines[patchLineOf(info)] = true
|
||||
}
|
||||
}
|
||||
|
||||
relPaths := repoRelativePaths(worktreePath, paths)
|
||||
if len(relPaths) == 0 {
|
||||
return aboveDiff, ""
|
||||
}
|
||||
|
||||
diffLines := strings.Split(strings.TrimSuffix(plainDiff(relPaths), "\n"), "\n")
|
||||
runs := map[string][2]int{}
|
||||
for i, parsed := range parseAllDiffLinesFromBuffer(diffLines) {
|
||||
if !parsed.ok {
|
||||
continue
|
||||
}
|
||||
info := diffLineInfoIn(worktreePath, parsed.parsed)
|
||||
selected := selection[info.Path]
|
||||
if selected == nil || !selected.covers(info) {
|
||||
continue
|
||||
}
|
||||
if run, ok := runs[info.Path]; ok {
|
||||
runs[info.Path] = [2]int{run[0], i}
|
||||
} else {
|
||||
runs[info.Path] = [2]int{i, i}
|
||||
}
|
||||
}
|
||||
|
||||
text := strings.Builder{}
|
||||
for _, path := range paths {
|
||||
run, ok := runs[path]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, line := range diffLines[run[0] : run[1]+1] {
|
||||
text.WriteString(line)
|
||||
text.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return aboveDiff, text.String()
|
||||
}
|
||||
|
||||
// textAboveDiff returns the text of the selected rows that sit above the diff, as they
|
||||
// read on screen. What a diff view shows before its first file — a commit's message and
|
||||
// git's summary of it — is part of no file's diff, so there is nothing to look those
|
||||
// rows up in and the rendering is all we have of them.
|
||||
//
|
||||
// It is "" for a view that shows no file at all: with no diff on screen there is
|
||||
// nothing for the text to be above, and a rendering we couldn't read as a diff is
|
||||
// exactly what we don't want on the clipboard.
|
||||
func (self *DiffLineHelper) textAboveDiff(view *gocui.View, first int, last int) string {
|
||||
firstRow, ok := view.BufferLineForViewLine(first)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
lastRow, ok := view.BufferLineForViewLine(last)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
startOfDiff := slices.IndexFunc(self.filePaths(view), func(path string) bool { return path != "" })
|
||||
if startOfDiff == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lastRow = min(lastRow, startOfDiff-1)
|
||||
if lastRow < firstRow {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(view.BufferLines()[firstRow:lastRow+1], "\n") + "\n"
|
||||
}
|
||||
|
||||
// selectedDiffLines is what a selection covers of one file's diff.
|
||||
type selectedDiffLines struct {
|
||||
// The lines of the file to look for, hunk headers among them, by the identity that
|
||||
// names them in any rendering of the diff.
|
||||
lines map[patchLine]bool
|
||||
// Whether the file's header is covered, in whole or in part.
|
||||
header bool
|
||||
}
|
||||
|
||||
// covers reports whether the given line of a file's diff is one the selection holds.
|
||||
//
|
||||
// A file header is answered for by kind rather than looked for: the two ways a row's
|
||||
// identity is recovered disagree about what to call it — a parse of the diff says the
|
||||
// file's first line, a renderer's record says the file has no line — and a rendering
|
||||
// may show the header as one row or as ten. So a selection that touches it holds every
|
||||
// line of it.
|
||||
func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
|
||||
if info.Type == types.DiffLineFileHeader {
|
||||
return self.header
|
||||
}
|
||||
return self.lines[patchLineOf(info)]
|
||||
}
|
||||
|
||||
// repoRelativePaths turns the absolute paths a diff line's identity carries into the
|
||||
// repo-relative ones git speaks, dropping any that lies outside the worktree — a diff
|
||||
// renderer states the path however it likes, and one we can't place is one we can't
|
||||
// ask git about.
|
||||
func repoRelativePaths(worktreePath string, paths []string) []string {
|
||||
relPaths := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
relPath, err := filepath.Rel(worktreePath, path)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
continue
|
||||
}
|
||||
relPaths = append(relPaths, filepath.ToSlash(relPath))
|
||||
}
|
||||
return relPaths
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// The questions a diff view can be asked about what it is showing — where the change
|
||||
// lines are, which block or file a row belongs to — answered in the view-line terms a
|
||||
// cursor and a click speak. They are all built on the identities recovered in
|
||||
// diff_line_helper.go, which is where the answering stops and the recovering starts.
|
||||
|
||||
// DiffLinesInViewRange returns the identity of every diff line shown by the rows in
|
||||
// the inclusive view-line range [first, last] of view's rendered diff, in display
|
||||
// order. Rows whose identity can't be recovered are left out, as are the wrapped
|
||||
// segments of a row already counted.
|
||||
//
|
||||
// A row can show more than one diff line — a side-by-side rendering puts a deletion
|
||||
// beside the addition replacing it — and all of them are reported: what the user
|
||||
// pointed at is the row, so everything on it is selected.
|
||||
func (self *DiffLineHelper) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
|
||||
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
|
||||
|
||||
infos := []types.DiffLineInfo{}
|
||||
previousBufferLine := -1
|
||||
for viewLine := first; viewLine <= last; viewLine++ {
|
||||
bufferLine, ok := view.BufferLineForViewLine(viewLine)
|
||||
if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) {
|
||||
continue
|
||||
}
|
||||
previousBufferLine = bufferLine
|
||||
infos = append(infos, identities[bufferLine]...)
|
||||
}
|
||||
return 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
|
||||
// selection is anchored on and navigation moves between.
|
||||
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
isChange := make([]bool, len(resolved))
|
||||
for i, r := range resolved {
|
||||
isChange[i] = r.ok && r.info.IsChange()
|
||||
}
|
||||
return isChange
|
||||
}
|
||||
|
||||
// FirstChangeLineInView returns the view line of the first change line on screen. It
|
||||
// is where the selection goes when the main view is focused by keyboard: focusing a
|
||||
// diff you are reading points at something in it without moving it, so the search
|
||||
// stops at the bottom of the viewport rather than going after a change further down.
|
||||
// ok is false when the viewport holds no change line — scrolled into a long stretch
|
||||
// of context, or past the last change.
|
||||
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
|
||||
top, bottom, ok := visibleBufferLines(view)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
||||
if isChange[i] {
|
||||
return view.ViewLineForBufferLine(i)
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// FirstChangeBlockInView returns the view line of the first change block on screen:
|
||||
// the first one that *begins* in the viewport, and failing that the one that reaches
|
||||
// into the viewport from above, whose start is off screen. Hunk mode wants that order
|
||||
// for the block it offers up on focus: preferably a block whose beginning the user can
|
||||
// see, rather than the tail of one they have scrolled past the start of. The block
|
||||
// bleeding in from above is kept as the answer for a change too long to fit on screen,
|
||||
// where there is no other. ok is false when the viewport shows no change line.
|
||||
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
|
||||
top, bottom, ok := visibleBufferLines(view)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
||||
if isChange[i] && (i == 0 || !isChange[i-1]) {
|
||||
return view.ViewLineForBufferLine(i)
|
||||
}
|
||||
}
|
||||
// A block covering the top line is one that began above it: nothing else can put a
|
||||
// change there once no block starts on screen.
|
||||
if top < len(isChange) && isChange[top] {
|
||||
return view.ViewLineForBufferLine(top)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// visibleBufferLines returns the first and last line of view's content that the
|
||||
// viewport shows any part of, for the queries that only care about what the user can
|
||||
// see. The last line is the one at the bottom edge, or the content's last when the
|
||||
// content ends above it. ok is false for a view showing no content at all.
|
||||
func visibleBufferLines(view *gocui.View) (int, int, bool) {
|
||||
top, ok := view.BufferLineForViewLine(view.OriginY())
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
|
||||
bottom, ok := view.BufferLineForViewLine(lastVisible)
|
||||
if !ok {
|
||||
return top, top, true
|
||||
}
|
||||
return top, bottom, true
|
||||
}
|
||||
|
||||
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
|
||||
// all, i.e. whether there is anything to select. It is false over a non-diff
|
||||
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
|
||||
// which are the cases where the focused main view shows no selection.
|
||||
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
|
||||
return lo.Contains(self.changeLines(view), true)
|
||||
}
|
||||
|
||||
// IsChangeLine reports whether the given view line of view's rendered diff is a
|
||||
// change line rather than context, a header, or an unresolvable row — i.e. whether
|
||||
// pointing at it points at something a patch could be built from.
|
||||
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
|
||||
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
|
||||
return ok && info.IsChange()
|
||||
}
|
||||
|
||||
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
|
||||
// is shown as one solid block of changes — every row of its diff a change of the same
|
||||
// kind, no context — which is what a newly added or deleted file looks like. That is
|
||||
// the case where widening the selection to the change block would select the file
|
||||
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
|
||||
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
|
||||
//
|
||||
// It says false while the diff is still being read in, since the rows that would
|
||||
// answer otherwise — a context line, a change of the other kind — may not have
|
||||
// arrived yet. That errs towards hunk mode, which is what the user asked for.
|
||||
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
|
||||
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
|
||||
return false
|
||||
}
|
||||
|
||||
anchor, ok := view.BufferLineForViewLine(changeViewLine)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
if anchor >= len(resolved) || !resolved[anchor].ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// The question is per file: a commit's diff may hold a newly added file next to an
|
||||
// edited one.
|
||||
path := resolved[anchor].info.Path
|
||||
kind := resolved[anchor].info.Type
|
||||
for _, row := range resolved {
|
||||
if !row.ok || row.info.Path != path {
|
||||
continue
|
||||
}
|
||||
if row.info.Type == types.DiffLineContext {
|
||||
return false
|
||||
}
|
||||
if row.info.IsChange() && row.info.Type != kind {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ChangeBlockBounds returns the inclusive view-line range of the change block to
|
||||
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
|
||||
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
|
||||
// single git @@ hunk may hold several. When the anchor is context, the block used is
|
||||
// the first at or below it, or — with nothing below, the cursor sitting past the last
|
||||
// change — the nearest above, so that hunk mode always has a block to select. ok is
|
||||
// false only when the diff holds no change line at all.
|
||||
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
isChange := self.changeLines(view)
|
||||
start := anchor
|
||||
for start < len(isChange) && !isChange[start] {
|
||||
start++
|
||||
}
|
||||
if start >= len(isChange) {
|
||||
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
end := start
|
||||
for start > 0 && isChange[start-1] {
|
||||
start--
|
||||
}
|
||||
for end < len(isChange)-1 && isChange[end+1] {
|
||||
end++
|
||||
}
|
||||
|
||||
startView, startOk := view.ViewLineForBufferLine(start)
|
||||
// The block's last line goes to its last view line, so that a line the view
|
||||
// wrapped is highlighted to its end rather than only where it begins.
|
||||
endView, endOk := view.LastViewLineForBufferLine(end)
|
||||
if !startOk || !endOk {
|
||||
return 0, 0, false
|
||||
}
|
||||
return startView, endView, true
|
||||
}
|
||||
|
||||
// SelectedHunkBounds returns the change block selected in hunk mode. The range
|
||||
// anchor stays on the block's far end when a click moves the cursor before its
|
||||
// handler runs, so it still identifies the selected block.
|
||||
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
|
||||
anchor := view.RangeSelectStartY()
|
||||
if anchor < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return self.ChangeBlockBounds(view, anchor)
|
||||
}
|
||||
|
||||
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
|
||||
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
|
||||
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
|
||||
// of the next block, forward=false the start of the previous one — from mid-block that
|
||||
// means the previous block, rather than the one we are in. ok is false when there's no
|
||||
// further block, so the caller leaves the view where it is.
|
||||
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target, ok := changeBlockStart(self.changeLines(view), anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// AdjacentFile returns the view line to move to for next/previous file navigation in
|
||||
// view's (possibly multi-file) rendered diff, starting from anchorViewLine: the first
|
||||
// located row of the neighbouring file, found where the rows' file changes. ok is
|
||||
// false at the first or last file.
|
||||
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target, ok := fileStart(self.filePaths(view), anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
|
||||
// empty for a row whose identity couldn't be recovered.
|
||||
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
paths := make([]string, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok {
|
||||
paths[i] = row.info.Path
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), the first located row of the file adjacent to `from`
|
||||
// in the given direction — the row file navigation lands on. It is the pure index
|
||||
// arithmetic behind AdjacentFile.
|
||||
//
|
||||
// A file is identified by its path, so we look for where the path changes, skipping
|
||||
// rows that carry none: those are the blank separator rows between files, or the
|
||||
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
||||
// So the landing row is the file's header wherever the source says so — a parseable
|
||||
// buffer, or a renderer that tags its headers — and the file's first content line
|
||||
// otherwise, which is an accepted degradation.
|
||||
func fileStart(paths []string, from int, forward bool) (int, bool) {
|
||||
anchorPath, ok := anchorFilePath(paths, from)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" && paths[i] != anchorPath {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Walk back past the current file (its rows and any unlocated ones) to the previous
|
||||
// file's last located row, then back over that whole file, landing on its first.
|
||||
i := from
|
||||
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prevPath := paths[i]
|
||||
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
|
||||
i--
|
||||
}
|
||||
for paths[i] != prevPath {
|
||||
i++
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
// below it that carries a path — the file whose content is at or below the top of the
|
||||
// view — falling back to the nearest above when there is nothing below. Scanning down
|
||||
// first matters because the anchor is often a file-header row that carries no path of
|
||||
// its own, whose nearest tagged row above is the *previous* file's content; taking
|
||||
// that would make next-file navigation jump back into the file just left, so a second
|
||||
// press wouldn't advance. ok is false when no row carries a path.
|
||||
func anchorFilePath(paths []string, from int) (string, bool) {
|
||||
if from < 0 {
|
||||
return "", false
|
||||
}
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
for i := min(from, len(paths)) - 1; i >= 0; i-- {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
|
||||
// line of the change block adjacent to `from` in the given direction. It is the pure
|
||||
// index arithmetic behind AdjacentChangeBlock.
|
||||
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
|
||||
if from < 0 || from >= len(isChange) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
i := from
|
||||
for i < len(isChange) && isChange[i] { // leave the current block
|
||||
i++
|
||||
}
|
||||
for i < len(isChange) && !isChange[i] { // skip the separating context
|
||||
i++
|
||||
}
|
||||
if i == len(isChange) {
|
||||
return 0, false
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
i := from
|
||||
for i >= 0 && isChange[i] { // leave the current block
|
||||
i--
|
||||
}
|
||||
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
for i > 0 && isChange[i-1] { // walk back to that block's first line
|
||||
i--
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChangeBlockStart(t *testing.T) {
|
||||
// A diff with three change blocks separated by context:
|
||||
// 0 file header 1 hunk header 2 context
|
||||
// 3 + 4 + (block A)
|
||||
// 5 context
|
||||
// 6 - (block B)
|
||||
// 7 context
|
||||
// 8 + (block C)
|
||||
isChange := []bool{false, false, false, true, true, false, true, false, true}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward from a header lands on the first block", 0, true, 3, true},
|
||||
{"forward from separating context lands on the next block", 5, true, 6, true},
|
||||
{"forward from the start of a block skips to the next", 3, true, 6, true},
|
||||
{"forward from inside a block skips the rest of it", 4, true, 6, true},
|
||||
{"forward from the last block finds nothing", 8, true, 0, false},
|
||||
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
|
||||
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
|
||||
{"backward from inside the first block finds nothing", 4, false, 0, false},
|
||||
{"backward from the first block's start finds nothing", 3, false, 0, false},
|
||||
{"backward from context lands on the preceding block's start", 7, false, 6, true},
|
||||
{"an anchor past the end finds nothing", 9, true, 0, false},
|
||||
{"a negative anchor finds nothing", -1, true, 0, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := changeBlockStart(isChange, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStart(t *testing.T) {
|
||||
// A parseable two-file diff: every row carries its file's path, headers included,
|
||||
// as the buffer parser reports it.
|
||||
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
|
||||
|
||||
// The same diff as a renderer that doesn't say which file its headers belong to
|
||||
// emits it: only content lines carry the path, so navigation can land no higher
|
||||
// than each file's first content line.
|
||||
contentOnly := []string{"", "", "a", "a", "", "", "b", "b"}
|
||||
|
||||
// Three such files, to exercise navigating from one file's untagged header to the
|
||||
// next: the row just above b's header is a's content, so the anchor's file has to
|
||||
// be found by scanning down (b) rather than up (a) — otherwise next-file would
|
||||
// jump back into b and a second press couldn't advance.
|
||||
contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
|
||||
|
||||
// A renderer that does tag its header rows: the file header and the hunk-header box
|
||||
// carry the file's path, but the blank separator rows around them carry nothing.
|
||||
// Navigation must land on the header's first row, not the blank line above it.
|
||||
// 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content
|
||||
// 8 blank 9-10 file hdr 11 blank 12-13 hunk hdr box 14 content
|
||||
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
paths []string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward lands on the next file's header", parseable, 1, true, 4, true},
|
||||
{"forward from the last file finds nothing", parseable, 5, true, 0, false},
|
||||
{"backward lands on the previous file's header", parseable, 5, false, 0, true},
|
||||
{"backward from the first file finds nothing", parseable, 1, false, 0, false},
|
||||
{"forward lands on the next file's first content line", contentOnly, 2, true, 6, true},
|
||||
{"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true},
|
||||
{"forward from an untagged header advances past it", contentOnly, 0, true, 6, true},
|
||||
{"a second forward press advances again", contentOnlyThree, 4, true, 10, true},
|
||||
{"forward lands on a tagged file header", headerTagged, 7, true, 9, true},
|
||||
{"backward lands on a tagged file header", headerTagged, 14, false, 1, true},
|
||||
{"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := fileStart(s.paths, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type Helpers struct {
|
||||
Snake *SnakeHelper
|
||||
// lives in context package because our contexts need it to render to main
|
||||
Diff *DiffHelper
|
||||
DiffLine *DiffLineHelper
|
||||
Repos *ReposHelper
|
||||
RecordDirectory *RecordDirectoryHelper
|
||||
Update *UpdateHelper
|
||||
@@ -76,6 +77,7 @@ func NewStubHelpers() *Helpers {
|
||||
Commits: &CommitsHelper{},
|
||||
Snake: &SnakeHelper{},
|
||||
Diff: &DiffHelper{},
|
||||
DiffLine: &DiffLineHelper{},
|
||||
Repos: &ReposHelper{},
|
||||
RecordDirectory: &RecordDirectoryHelper{},
|
||||
Update: &UpdateHelper{},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,9 @@ package controllers
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MainViewController struct {
|
||||
@@ -12,6 +14,9 @@ type MainViewController struct {
|
||||
|
||||
context *context.MainContext
|
||||
otherContext *context.MainContext
|
||||
|
||||
dragAutoscroller *helpers.DragAutoscroller
|
||||
draggingWithMouse bool
|
||||
}
|
||||
|
||||
var _ types.IController = &MainViewController{}
|
||||
@@ -21,12 +26,19 @@ func NewMainViewController(
|
||||
context *context.MainContext,
|
||||
otherContext *context.MainContext,
|
||||
) *MainViewController {
|
||||
return &MainViewController{
|
||||
controller := &MainViewController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
context: context,
|
||||
otherContext: otherContext,
|
||||
}
|
||||
controller.dragAutoscroller = helpers.NewDragAutoscroller(
|
||||
c.HelperCommon,
|
||||
context,
|
||||
controller.canDragAutoscroll,
|
||||
controller.handleDragAutoscroll,
|
||||
)
|
||||
return controller
|
||||
}
|
||||
|
||||
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -38,10 +50,75 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
|
||||
Handler: self.toggleSelectHunk,
|
||||
DescriptionFunc: self.diffSelectionDescription(func() string {
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
return self.c.Tr.SelectLineByLine
|
||||
}
|
||||
return self.c.Tr.SelectHunk
|
||||
}),
|
||||
Description: self.c.Tr.ToggleSelectHunk,
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
|
||||
Handler: self.toggleRangeSelect,
|
||||
Description: self.c.Tr.ToggleRangeSelect,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
||||
Handler: self.editLine,
|
||||
Description: self.c.Tr.EditFile,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard),
|
||||
Handler: self.copySelection,
|
||||
Description: self.c.Tr.CopySelectedTextToClipboard,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.CopySelectedTextToClipboard),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
|
||||
Handler: self.prevChangeBlock,
|
||||
Description: self.c.Tr.PrevHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
|
||||
Handler: self.nextChangeBlock,
|
||||
Description: self.c.Tr.NextHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
|
||||
Handler: self.prevFile,
|
||||
Description: self.c.Tr.PrevFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextFile),
|
||||
Handler: self.nextFile,
|
||||
Description: self.c.Tr.NextFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
||||
Handler: self.escape,
|
||||
Description: self.c.Tr.ExitFocusedMainView,
|
||||
DescriptionFunc: self.escapeDescription,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
@@ -51,6 +128,28 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
Description: self.c.Tr.StartSearch,
|
||||
Tag: "navigation",
|
||||
},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
|
||||
{
|
||||
Tag: "navigation",
|
||||
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
|
||||
Handler: self.extendRangeUp,
|
||||
Description: self.c.Tr.RangeSelectUp,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Tag: "navigation",
|
||||
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
|
||||
Handler: self.extendRangeDown,
|
||||
Description: self.c.Tr.RangeSelectDown,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +167,19 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
|
||||
Handler: self.onClickInOtherViewOfMainViewPair,
|
||||
FocusedView: self.otherContext.GetViewName(),
|
||||
},
|
||||
{
|
||||
// Dragging after a click extends a range selection from the clicked line.
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
Modifier: gocui.ModMotion,
|
||||
Handler: self.onDragInFocusedView,
|
||||
FocusedView: self.context.GetViewName(),
|
||||
},
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseRelease,
|
||||
Handler: self.onDragRelease,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,35 +188,748 @@ func (self *MainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *MainViewController) togglePanel() error {
|
||||
if self.otherContext.GetView().Visible {
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if !self.otherContext.GetView().Visible {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Whether the pair holds a diff is decided by the side panel beneath, which
|
||||
// NextInStack only finds while our context is still the focused main view, so
|
||||
// read it before pushing the other pane.
|
||||
isDiff := self.isDiffView()
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if isDiff {
|
||||
establishDiffSelection(self.c, self.otherContext, -1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escape dismisses the selection a step at a time before leaving the view: a range
|
||||
// collapses to its cursor line, and hunk mode the user turned on goes back to
|
||||
// line-by-line. Hunk mode that is merely the configured default is not something to
|
||||
// escape from, so there escape leaves.
|
||||
func (self *MainViewController) escape() error {
|
||||
if self.selectingRange() || self.selectingHunkEnabledByUser() {
|
||||
self.context.ResetDiffSelectMode()
|
||||
return nil
|
||||
}
|
||||
|
||||
self.c.Context().Pop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
sidePanelContext := self.c.Context().NextInStack(self.context)
|
||||
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
|
||||
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
|
||||
func (self *MainViewController) escapeDescription() string {
|
||||
if self.selectingRange() {
|
||||
return self.c.Tr.DismissRangeSelect
|
||||
}
|
||||
if self.selectingHunkEnabledByUser() {
|
||||
return self.c.Tr.SelectLineByLine
|
||||
}
|
||||
return self.c.Tr.ExitFocusedMainView
|
||||
}
|
||||
|
||||
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
|
||||
// asked for it, as opposed to it being the configured default.
|
||||
func (self *MainViewController) selectingHunkEnabledByUser() bool {
|
||||
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
|
||||
}
|
||||
|
||||
// isDiffView reports whether the focused main view currently shows a diff, and so
|
||||
// shows a selection. See types.DiffMainViewContext.
|
||||
func (self *MainViewController) isDiffView() bool {
|
||||
return self.diffMainViewType() != types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// diffMainViewType reports what the diff in the focused main view belongs to, taken
|
||||
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
|
||||
// stack or has no diff panel beneath it. The IsInStack guard is essential:
|
||||
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
|
||||
// leads here) also runs for off-stack panes — at startup and while generating the
|
||||
// cheatsheets, where the stack is empty.
|
||||
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
|
||||
return diffContext.GetDiffMainViewType()
|
||||
}
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// diffSource returns the panel beneath the focused main view, as the thing that can
|
||||
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
|
||||
// panel beneath shows no diff.
|
||||
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return nil
|
||||
}
|
||||
sidePanel := self.c.Context().NextInStack(self.context)
|
||||
if sidePanel == nil {
|
||||
return nil
|
||||
}
|
||||
return sidePanel.GetFocusedMainViewDiffSource()
|
||||
}
|
||||
|
||||
// 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 := view.SelectedLineRange()
|
||||
aboveDiff, fromDiff := self.c.Helpers().DiffLine.PlainDiffOfSelection(view, first, last,
|
||||
func(paths []string) string { return source.PlainDiff(self.context, paths) })
|
||||
if aboveDiff == "" {
|
||||
// Only text that is all diff has a +/- column to lose: a line of a commit message
|
||||
// may begin with a '-' without being a deletion of anything.
|
||||
fromDiff = dropDiffPrefix(fromDiff)
|
||||
}
|
||||
return aboveDiff + fromDiff
|
||||
}
|
||||
|
||||
// diffSelectState returns this pane's diff selection mode state.
|
||||
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
|
||||
return self.context.DiffSelectState()
|
||||
}
|
||||
|
||||
// diffSelectionDescription qualifies the description of a command that acts on the
|
||||
// selection, so that it is listed only where it applies: the main view also shows
|
||||
// content with nothing to select in it — a branch's commit log, the status dashboard —
|
||||
// and a command with no description is left out of the keybindings menu.
|
||||
//
|
||||
// The static Description stays as it is: the cheatsheets are generated from that, and
|
||||
// they document what a key does rather than when it applies.
|
||||
func (self *MainViewController) diffSelectionDescription(describe func() string) func() string {
|
||||
return func() string {
|
||||
if !self.isDiffView() {
|
||||
return ""
|
||||
}
|
||||
return describe()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
|
||||
return self.diffSelectionDescription(func() string { return description })
|
||||
}
|
||||
|
||||
// diffSelectionDisabledReason disables the commands that act on the selection while
|
||||
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
|
||||
// file, an empty commit) or which is showing a placeholder message.
|
||||
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
|
||||
if !self.context.GetView().Highlight {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
// Carry the select mode over from the pane we're leaving, so that clicking into
|
||||
// the other pane keeps hunk mode even the first time we enter it — its own mode
|
||||
// would otherwise still be the default single line until it had been focused at
|
||||
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
|
||||
// where the click landed.
|
||||
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{})
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
}
|
||||
|
||||
// onDragInFocusedView extends a range selection as the mouse is dragged after a
|
||||
// click, anchored at the line the click landed on rather than wherever the click left
|
||||
// the selection — a click can select a whole hunk, whose far end would otherwise
|
||||
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
|
||||
// clicked line to the line under the cursor, which gocui has already moved here.
|
||||
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
view := self.context.GetView()
|
||||
if !self.isDiffView() || !view.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
sel.RangeIsSticky = false
|
||||
sel.UserEnabledHunkMode = false
|
||||
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
|
||||
|
||||
// A drag that reaches the edge of the view keeps going: mouse capture means the
|
||||
// pointer can be dragged past the edge, and there is more diff down there than
|
||||
// fits on screen. opts.Y is where the pointer is in the content, which the
|
||||
// autoscroller wants relative to the viewport.
|
||||
self.draggingWithMouse = true
|
||||
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
|
||||
self.dragAutoscroller.Update(opts.Y - originY)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
|
||||
self.draggingWithMouse = false
|
||||
self.dragAutoscroller.Cancel()
|
||||
|
||||
// The drag moved the selection without going through showSelectionAtLine: gocui
|
||||
// moves the cursor for it. Let the search catch up with where it ended.
|
||||
self.context.GetView().SetNearestSearchPosition()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
|
||||
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
|
||||
// otherwise the pointer would keep driving a view that no longer has focus.
|
||||
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
|
||||
return func(types.OnFocusLostOpts) {
|
||||
self.dragAutoscroller.Cancel()
|
||||
if self.draggingWithMouse {
|
||||
self.draggingWithMouse = false
|
||||
self.c.GocuiGui().CancelMouseCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
|
||||
// actually extending a range in a diff. Scrolling down also has to keep the lazily
|
||||
// loaded content ahead of the scroll, or it would stop at the loaded edge.
|
||||
func (self *MainViewController) canDragAutoscroll(direction int) bool {
|
||||
if !self.draggingWithMouse || !self.isDiffView() {
|
||||
return false
|
||||
}
|
||||
view := self.context.GetView()
|
||||
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
|
||||
return false
|
||||
}
|
||||
if direction > 0 {
|
||||
self.c.ReadLinesToFillView(view)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleDragAutoscroll extends the selection to the line the pointer ends up over
|
||||
// after the autoscroller has scrolled, leaving the range anchored where the drag
|
||||
// started. It reports whether the autoscroll should carry on.
|
||||
//
|
||||
// The pointer is usually outside the view by now — that is what mouse capture is for —
|
||||
// so the line it is over is clamped to the visible ones, leaving the selection's far
|
||||
// end at the edge the scroll is moving towards.
|
||||
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
|
||||
if !self.canDragAutoscroll(0) {
|
||||
return false
|
||||
}
|
||||
view := self.context.GetView()
|
||||
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
|
||||
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
|
||||
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
|
||||
return true
|
||||
}
|
||||
|
||||
// selectClickedDiffLine sets the focused main view's selection from a click at the
|
||||
// given view line. In hunk mode, clicking inside the selected block collapses it to
|
||||
// that line; clicking a change line outside it keeps hunk mode and selects that block.
|
||||
// A click on context, or any click outside hunk mode, selects just that line too.
|
||||
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
|
||||
if !self.isDiffView() {
|
||||
return
|
||||
}
|
||||
view := self.context.GetView()
|
||||
// Remember where the click landed so that a drag that follows anchors its range
|
||||
// there, even when this click selects a whole hunk.
|
||||
self.context.SetDragAnchorViewLine(viewLine)
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
|
||||
viewLine >= start && viewLine <= end {
|
||||
self.context.ResetDiffSelectMode()
|
||||
showSelectionAtLine(view, viewLine, false)
|
||||
return
|
||||
}
|
||||
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
|
||||
self.selectHunkAround(viewLine, false)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.context.ResetDiffSelectMode()
|
||||
showSelectionAtLine(view, viewLine, false)
|
||||
}
|
||||
|
||||
// establishDiffSelection turns on the focused main view's selection once the view has
|
||||
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
|
||||
// keyboard focus, which points at no particular line and so starts at the first
|
||||
// change line on screen.
|
||||
//
|
||||
// Focusing never moves the view: you focus the diff you are reading in order to point
|
||||
// at something in it, so the selection goes where you are looking rather than the
|
||||
// view going where the selection would like to be. With no change line on screen at
|
||||
// all — a long stretch of context — it lands on the middle visible line, the likeliest
|
||||
// one to be the one being read.
|
||||
//
|
||||
// With hunk mode configured as the default the selection widens to the whole change
|
||||
// block: keyboard focus lands on the first block on screen, and a click on a change
|
||||
// line selects that line's block, ready to act on. A click on context still selects
|
||||
// just that line — the click points at it precisely, so it stays editable.
|
||||
func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) {
|
||||
mainContext.ResetDiffSelectMode()
|
||||
view := mainContext.GetView()
|
||||
|
||||
// The panel beneath renders a diff, but that diff may hold nothing to act on: a
|
||||
// binary file, or an empty commit. Rendering it worked that out, so the pane is
|
||||
// already showing no selection and there is nowhere to put one.
|
||||
if !c.Helpers().DiffLine.ViewHasChangeLines(view) {
|
||||
return
|
||||
}
|
||||
|
||||
if clickedViewLine >= 0 {
|
||||
// Remember where the click landed so that a drag that follows anchors its range
|
||||
// there, even when this click selects a whole hunk.
|
||||
mainContext.SetDragAnchorViewLine(clickedViewLine)
|
||||
if hunkModeApplies(c, view, clickedViewLine) &&
|
||||
c.Helpers().DiffLine.IsChangeLine(view, clickedViewLine) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
selectDiffHunk(c, mainContext, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
showSelectionAtLine(view, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := changeToSelectOnScreen(c, view)
|
||||
if !ok {
|
||||
showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
|
||||
return
|
||||
}
|
||||
if hunkModeApplies(c, view, target) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
selectDiffHunk(c, mainContext, target, false)
|
||||
return
|
||||
}
|
||||
showSelectionAtLine(view, target, false)
|
||||
}
|
||||
|
||||
// changeToSelectOnScreen returns the change line keyboard focus establishes the
|
||||
// selection on. In hunk mode that is the first block that begins on screen, so that
|
||||
// the block being offered up is one the user can see the extent of, falling back to a
|
||||
// block that reaches into the view from above — a change longer than the screen, where
|
||||
// there is nothing else to offer. Line by line it is simply the first change line on
|
||||
// screen. ok is false when the viewport shows no change at all.
|
||||
func changeToSelectOnScreen(c *ControllerCommon, view *gocui.View) (int, bool) {
|
||||
if c.UserConfig().Gui.UseHunkModeInStagingView {
|
||||
return c.Helpers().DiffLine.FirstChangeBlockInView(view)
|
||||
}
|
||||
return c.Helpers().DiffLine.FirstChangeLineInView(view)
|
||||
}
|
||||
|
||||
// hunkModeApplies reports whether an established selection should start out as the
|
||||
// whole change block around the given change line. That's what the config asks for,
|
||||
// except over a file shown as one solid block of changes, where it would select the
|
||||
// whole file — see DiffLineHelper.IsSingleHunkForWholeFile.
|
||||
func hunkModeApplies(c *ControllerCommon, view *gocui.View, changeViewLine int) bool {
|
||||
return c.UserConfig().Gui.UseHunkModeInStagingView &&
|
||||
!c.Helpers().DiffLine.IsSingleHunkForWholeFile(view, changeViewLine)
|
||||
}
|
||||
|
||||
// showSelectionAtLine moves the focused main view's selection to the given view line,
|
||||
// clamped to the content. scrollIntoView scrolls the line into view when it's
|
||||
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
|
||||
// screen already.
|
||||
func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
|
||||
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
|
||||
|
||||
// A search carries on from where the selection now is, so that stepping to the
|
||||
// next match goes to the one after it rather than the one after the match the
|
||||
// user last stepped to.
|
||||
view.SetNearestSearchPosition()
|
||||
}
|
||||
|
||||
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
|
||||
selectDiffHunk(self.c, self.context, changeViewLine, scrollIntoView)
|
||||
}
|
||||
|
||||
// selectDiffHunk selects the whole change block around the given change line, for
|
||||
// hunk mode: the cursor goes to the block's first line and the range anchor to its
|
||||
// last, so the native range highlight spans the block. With no block to be found —
|
||||
// a diff with no changes in it — it falls back to a single-line selection.
|
||||
//
|
||||
// scrollIntoView brings the block's first line on screen, for the commands that mean
|
||||
// to go there; a click leaves it false, so that the view doesn't move under the mouse
|
||||
// when the block the click landed in starts above the viewport.
|
||||
func selectDiffHunk(
|
||||
c *ControllerCommon, mainContext *context.MainContext, changeViewLine int, scrollIntoView bool,
|
||||
) {
|
||||
view := mainContext.GetView()
|
||||
start, end, ok := c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine)
|
||||
if !ok {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeLine
|
||||
view.CancelRangeSelect()
|
||||
showSelectionAtLine(view, changeViewLine, scrollIntoView)
|
||||
return
|
||||
}
|
||||
view.SetRangeSelectStart(end)
|
||||
showSelectionAtLine(view, start, scrollIntoView)
|
||||
}
|
||||
|
||||
// navigate moves the focused main view to the row find locates from the current
|
||||
// anchor — the selected line when a selection is showing, otherwise the top visible
|
||||
// line. With a selection we move it there and scroll it into view, re-selecting the
|
||||
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
|
||||
// to the top without selecting anything.
|
||||
// alignTop says what a jump does with a target it has to scroll to: bring it to the
|
||||
// top of the view, or leave the scrolling to place it as it sees fit.
|
||||
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
anchor := v.OriginY()
|
||||
if v.Highlight {
|
||||
anchor = v.SelectedLineIdx()
|
||||
}
|
||||
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
return
|
||||
}
|
||||
if !forward {
|
||||
// Everything above the anchor has loaded, so a backward target that wasn't
|
||||
// found doesn't exist.
|
||||
return
|
||||
}
|
||||
|
||||
// The diff loads lazily, so a target below the loaded portion isn't there to be
|
||||
// found yet. Read the rest of it in and look again before concluding there is none.
|
||||
manager := self.c.GetViewBufferManagerForView(v)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
|
||||
// the anchor view line to start from, and the direction.
|
||||
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
|
||||
|
||||
func (self *MainViewController) nextChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFile and prevFile bring the file they go to to the top of the view, since what
|
||||
// you are going there for is the file, and the more of it is on screen the better.
|
||||
func (self *MainViewController) nextFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
|
||||
// screen if it isn't already.
|
||||
//
|
||||
// alignTop asks for the target to become the view's top line, so that everything that
|
||||
// begins there is on screen. It only applies to a target the view has to scroll to: a
|
||||
// jump to something already on screen leaves the view alone, there being nothing to
|
||||
// gain from moving what the user is looking at. In hunk mode what ends up selected is
|
||||
// the first change block at or below the target, which a large context size can put
|
||||
// further down than a screenful; the selection is then scrolled into view as any other
|
||||
// jump's is, and the alignment gives way to that.
|
||||
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
v.SetOrigin(0, target)
|
||||
return
|
||||
}
|
||||
if alignTop {
|
||||
self.scrollTargetToTop(target)
|
||||
}
|
||||
// Jumping to another block or file moves the cursor without shift held, so a
|
||||
// range that grows only while shift is held collapses rather than stretching all
|
||||
// the way to the target. A sticky range stretches instead; this is the point of
|
||||
// being sticky.
|
||||
self.collapseNonStickyRange()
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
self.selectHunkAround(target, true)
|
||||
return
|
||||
}
|
||||
// Line mode leaves a single-line selection at the target; an active range extends
|
||||
// to it, the anchor being untouched.
|
||||
showSelectionAtLine(v, target, true)
|
||||
}
|
||||
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
// the view where it is when that row is on screen already. The last screenful of the
|
||||
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
|
||||
// showing.
|
||||
func (self *MainViewController) scrollTargetToTop(target int) {
|
||||
view := self.context.GetView()
|
||||
originY, height := self.context.GetViewTrait().ViewPortYBounds()
|
||||
if target >= originY && target < originY+height {
|
||||
return
|
||||
}
|
||||
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
|
||||
}
|
||||
|
||||
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
|
||||
// configured scroll-off margin, reading more content in first when moving down. The
|
||||
// range anchor is left untouched, so this extends or contracts a range and just moves
|
||||
// the selected line otherwise.
|
||||
func (self *MainViewController) moveCursor(delta int) {
|
||||
v := self.context.GetView()
|
||||
if delta > 0 {
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
before := v.SelectedLineIdx()
|
||||
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
|
||||
if delta == -1 {
|
||||
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
|
||||
} else if delta == 1 {
|
||||
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
|
||||
}
|
||||
showSelectionAtLine(v, after, true)
|
||||
}
|
||||
|
||||
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
|
||||
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
|
||||
// sticky range is kept, so the move extends it.
|
||||
func (self *MainViewController) collapseForLineMove() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeHunk {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
return
|
||||
}
|
||||
self.collapseNonStickyRange()
|
||||
}
|
||||
|
||||
// collapseNonStickyRange drops a range that only grows while shift is held back to a
|
||||
// single line at the cursor.
|
||||
func (self *MainViewController) collapseNonStickyRange() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// adjustSelection moves the selection by delta view lines, for the plain up/down and
|
||||
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
|
||||
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
|
||||
// a single line on a plain move. With no selection — non-diff content — it scrolls.
|
||||
func (self *MainViewController) adjustSelection(delta int) {
|
||||
if !self.context.GetView().Highlight {
|
||||
self.handleLineChange(delta)
|
||||
return
|
||||
}
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
|
||||
return
|
||||
}
|
||||
self.collapseForLineMove()
|
||||
self.moveCursor(delta)
|
||||
}
|
||||
|
||||
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
|
||||
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
|
||||
func (self *MainViewController) selectAbsoluteLine(target int) {
|
||||
self.collapseForLineMove()
|
||||
showSelectionAtLine(self.context.GetView(), target, true)
|
||||
}
|
||||
|
||||
// selectingRange reports whether a range selection is currently active: we're in
|
||||
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
|
||||
// non-sticky range that has actually been extended.
|
||||
func (self *MainViewController) selectingRange() bool {
|
||||
if self.diffSelectState().Mode != types.DiffSelectModeRange {
|
||||
return false
|
||||
}
|
||||
start, end := self.context.GetView().SelectedLineRange()
|
||||
return self.diffSelectState().RangeIsSticky || start != end
|
||||
}
|
||||
|
||||
// toggleSelectHunk switches between selecting the change block around the cursor and
|
||||
// a single line.
|
||||
func (self *MainViewController) toggleSelectHunk() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeHunk {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = types.DiffSelectModeHunk
|
||||
sel.UserEnabledHunkMode = true
|
||||
self.selectHunkAround(v.SelectedLineIdx(), true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
|
||||
// up/down keys extend.
|
||||
func (self *MainViewController) toggleRangeSelect() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if self.selectingRange() {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
sel.RangeIsSticky = true
|
||||
v.SetRangeSelectStart(v.SelectedLineIdx())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extendRange grows a non-sticky range selection by one line in response to
|
||||
// shift+up/down, starting one at the cursor if there isn't one yet.
|
||||
func (self *MainViewController) extendRange(forward bool) error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
return nil
|
||||
}
|
||||
sel := self.diffSelectState()
|
||||
if !self.selectingRange() {
|
||||
sel.Mode = types.DiffSelectModeRange
|
||||
v.SetRangeSelectStart(v.SelectedLineIdx())
|
||||
}
|
||||
sel.RangeIsSticky = false
|
||||
if forward {
|
||||
self.moveCursor(1)
|
||||
} else {
|
||||
self.moveCursor(-1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) extendRangeUp() error {
|
||||
return self.extendRange(false)
|
||||
}
|
||||
|
||||
func (self *MainViewController) extendRangeDown() error {
|
||||
return self.extendRange(true)
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleLineChange(delta int) {
|
||||
v := self.context.GetView()
|
||||
if delta < 0 {
|
||||
v.ScrollUp(-delta)
|
||||
} else {
|
||||
v.ScrollDown(delta)
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevLine() error {
|
||||
self.adjustSelection(-1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextLine() error {
|
||||
self.adjustSelection(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevPage() error {
|
||||
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextPage() error {
|
||||
self.adjustSelection(self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleGotoTop() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleGotoBottom() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) editLine() error {
|
||||
view := self.context.GetView()
|
||||
if !view.Highlight {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A file-header row points at the file as a whole rather than at a line in it, so
|
||||
// it opens the file without jumping anywhere — as pressing edit on a file in a side
|
||||
// panel does.
|
||||
if info.Type == types.DiffLineFileHeader {
|
||||
return self.c.Helpers().Files.EditFiles([]string{info.Path})
|
||||
}
|
||||
|
||||
// The diff may be of an older commit, whose line numbers aren't the file's current
|
||||
// ones, so they have to be carried forward before we can point an editor at them.
|
||||
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
|
||||
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
|
||||
}
|
||||
|
||||
func (self *MainViewController) openSearch() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
|
||||
@@ -37,6 +37,20 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
|
||||
return self.c.Contexts().ReflogCommits
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self
|
||||
}
|
||||
|
||||
// PlainDiff hands out the reflog entry's diff for the given files — the same diff its
|
||||
// main view shows, only without the entry's message and stat above it.
|
||||
func (self *ReflogCommitsController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
|
||||
commit := self.context().GetSelected()
|
||||
if commit == nil {
|
||||
return ""
|
||||
}
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(commit.ParentRefName(), commit.RefName(), paths)
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
return func() {
|
||||
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -61,21 +62,30 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary)
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
// Usually the main pane, but the content can be in the secondary one alone: a file
|
||||
// with nothing but staged changes shows them there.
|
||||
mainViewContext := self.c.Contexts().Normal
|
||||
if self.c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly {
|
||||
mainViewContext = self.c.Contexts().NormalSecondary
|
||||
}
|
||||
return self.focusMainView(mainViewContext, -1)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
|
||||
if context, ok := mainViewContext.(types.ISearchableContext); ok {
|
||||
context.ClearSearchString()
|
||||
}
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
|
||||
mainViewContext.ClearSearchString()
|
||||
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
|
||||
|
||||
if _, ok := self.context.(types.DiffMainViewContext); ok {
|
||||
establishDiffSelection(self.c, mainViewContext, clickedLineIdx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ func (self *ToggleWhitespaceAction) Call() error {
|
||||
|
||||
self.c.UserConfig().Git.IgnoreWhitespaceInDiffView = !self.c.UserConfig().Git.IgnoreWhitespaceInDiffView
|
||||
|
||||
// You toggle this to see whether what you are looking at is more than
|
||||
// reindentation, so that is the thing to keep in front of you — even though
|
||||
// ignoring whitespace, unlike the other ways of re-rendering a diff, can take
|
||||
// the line away entirely along with the hunk or file it was in.
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
self.c.Helpers().DiffLine.PreserveDiffPositionOnRerender(self.c.Contexts().NormalSecondary.GetView())
|
||||
self.c.Context().CurrentSide().HandleRenderToMain()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type ViewSelectionControllerFactory struct {
|
||||
c *ControllerCommon
|
||||
}
|
||||
|
||||
func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory {
|
||||
return &ViewSelectionControllerFactory{
|
||||
c: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController {
|
||||
return &ViewSelectionController{
|
||||
baseController: baseController{},
|
||||
c: self.c,
|
||||
context: context,
|
||||
}
|
||||
}
|
||||
|
||||
type ViewSelectionController struct {
|
||||
baseController
|
||||
c *ControllerCommon
|
||||
|
||||
context types.Context
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) Context() types.Context {
|
||||
return self.context
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
return []*types.Binding{
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
return []*gocui.ViewMouseBinding{}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleLineChange(delta int) {
|
||||
v := self.Context().GetView()
|
||||
if delta < 0 {
|
||||
v.ScrollUp(-delta)
|
||||
} else {
|
||||
v.ScrollDown(delta)
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handlePrevLine() error {
|
||||
self.handleLineChange(-1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleNextLine() error {
|
||||
self.handleLineChange(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handlePrevPage() error {
|
||||
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleNextPage() error {
|
||||
self.handleLineChange(self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleGotoTop() error {
|
||||
v := self.Context().GetView()
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleGotoBottom() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
v := self.Context().GetView()
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,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 {
|
||||
|
||||
+229
-4
@@ -110,14 +110,19 @@ 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 {
|
||||
gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary)
|
||||
} else if opts.Pair.Secondary != nil {
|
||||
opts.Pair.Secondary.GetView().Clear()
|
||||
gui.clearMainView(opts.Pair.Secondary)
|
||||
}
|
||||
|
||||
// Reset the scroll positions of all the other main views. We do this after
|
||||
@@ -134,11 +139,231 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
gui.splitMainPanel(opts.Secondary != nil)
|
||||
gui.setMainPanes(panes)
|
||||
}
|
||||
|
||||
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
|
||||
gui.State.SplitMainPanel = splitMainPanel
|
||||
// handOverMainSection carries the content of the main section from the pane that has
|
||||
// been showing it on its own to the pane about to, when a render moves the section's
|
||||
// content from one to the other — a file's changes going from unstaged to staged, say.
|
||||
//
|
||||
// The section is one region of the screen to the user, so a change of which pane holds
|
||||
// it has to look like that region re-rendering rather than blanking and filling in
|
||||
// again: the incoming pane shows what the outgoing one was showing, where it was
|
||||
// showing it, until its own render has read enough to be swapped in. It renders from
|
||||
// the top when it does, the content it took over not being its own (see
|
||||
// clearMainView).
|
||||
func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) {
|
||||
// The lower pane is always the same view, being the only one a render can leave
|
||||
// holding the section on its own; the upper one is whichever view of the main
|
||||
// window this render is for, which moveMainContextPairToTop has just given a copy
|
||||
// of what that window was showing.
|
||||
upper, lower := pair.Main.GetView(), gui.Views.Secondary
|
||||
|
||||
var from, to *gocui.View
|
||||
switch {
|
||||
case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly:
|
||||
from, to = upper, lower
|
||||
case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly:
|
||||
from, to = lower, upper
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
gui.g.CopyContent(from, to)
|
||||
}
|
||||
|
||||
// mainPanesFor says which panes the given render occupies: the one it has content for,
|
||||
// or both when it has content for both.
|
||||
func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
|
||||
switch {
|
||||
case opts.Secondary == nil:
|
||||
return types.MainPaneOnly
|
||||
case opts.Main == nil:
|
||||
return types.SecondaryPaneOnly
|
||||
default:
|
||||
return types.BothMainPanes
|
||||
}
|
||||
}
|
||||
|
||||
// clampDiffSelectionToContent brings the focused main view's selection back onto the
|
||||
// content when the render that just finished left the diff with fewer lines than the
|
||||
// selection was on — a diff renderer that renders the same diff more compactly, a
|
||||
// smaller context size. That selection lives in the view rather than in a model, so
|
||||
// nothing else re-derives it, and past the end of the content it isn't drawn at all,
|
||||
// which reads as having no selection until an arrow key brings it back.
|
||||
//
|
||||
// Called at end of input, when the content is final: doing it while the render is
|
||||
// still loading would drag the selection to a line that only looks like the last one.
|
||||
// Only these two views need it; every other view's selection is derived from a model
|
||||
// as it renders, and so is clamped along with it.
|
||||
func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
|
||||
if gui.mainContextForView(view) == nil {
|
||||
return
|
||||
}
|
||||
if !view.Highlight {
|
||||
return
|
||||
}
|
||||
|
||||
if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine {
|
||||
view.FocusPoint(0, max(0, lastLine), true)
|
||||
}
|
||||
}
|
||||
|
||||
// clearMainView empties a pane that is being given nothing to show, selection and all.
|
||||
//
|
||||
// An emptied pane is showing nothing, so it also goes back to the top and stops
|
||||
// claiming the render it was showing: whatever it is given next is content the user
|
||||
// hasn't seen there, and is shown from the top like any other.
|
||||
func (gui *Gui) clearMainView(mainContext types.Context) {
|
||||
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
|
||||
// selection to sit on, from what it is now showing: only beneath a panel whose main
|
||||
// view is a diff, and only while that diff holds something to select — never over a
|
||||
// message like "No changed files", and never over a diff with nothing in it, such as a
|
||||
// binary file's or an empty commit's. Whether the selection is then drawn, and drawn as
|
||||
// the active one, follows from the context stack.
|
||||
//
|
||||
// It is asked wherever the pane's content changes: as a string is rendered, at the
|
||||
// paint that reveals a command's output, with every further batch of that output, and
|
||||
// once it has been read to the end. contentIsComplete tells those apart, since a render
|
||||
// still being read can leave the question open (see diffPaneHasSomethingToSelect). The
|
||||
// pane never answers from the render before it, and a render that leaves the question
|
||||
// open is read on until it doesn't, so the answer is always about what is there.
|
||||
func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplete bool) {
|
||||
mainContext := gui.mainContextForView(view)
|
||||
if mainContext == nil {
|
||||
return
|
||||
}
|
||||
|
||||
gui.dropAnAnswerAboutAnotherRender(mainContext, view)
|
||||
|
||||
if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect(
|
||||
mainContext, view, contentIsComplete,
|
||||
); known {
|
||||
mainContext.SetHasSelectableContent(hasSomethingToSelect)
|
||||
gui.State.ContextMgr.UpdateSelectionHighlights()
|
||||
} else {
|
||||
gui.readOnUntilTheDiffPaneCanTell(view)
|
||||
}
|
||||
}
|
||||
|
||||
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
|
||||
// of an earlier render, so that this one starts from no answer rather than inheriting
|
||||
// one. An answer about other content says nothing about this content: carried over, it
|
||||
// shows a selection over a diff that may have nothing to select, or hides one over a
|
||||
// diff that has.
|
||||
//
|
||||
// A re-render of the same content keeps its answer, and with it the selection drawn
|
||||
// over it, since that answer is still about what the pane is showing.
|
||||
func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) {
|
||||
manager := gui.getViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() {
|
||||
mainContext.SetSelectableContentRenderKey(key)
|
||||
mainContext.SetHasSelectableContent(false)
|
||||
gui.State.ContextMgr.UpdateSelectionHighlights()
|
||||
}
|
||||
}
|
||||
|
||||
// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of
|
||||
// it, while the pane still can't say whether there is anything in it to select.
|
||||
//
|
||||
// A render is asked for as many lines as the scrollbar needs (see
|
||||
// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the
|
||||
// diffstat of a commit touching thousands of files, or a commit message thousands of
|
||||
// lines long. Without this the pane would be left with no answer until the user
|
||||
// scrolled far enough to ask for the rest themselves, which is no way to find out
|
||||
// whether a diff can be acted on. Another render's worth is asked for each time, so the
|
||||
// reading stops soon after the first change line, and only runs to the end of a diff
|
||||
// that has none.
|
||||
func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
|
||||
manager := gui.getViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
step := gui.linesToReadFromCmdTask(view).Total
|
||||
if step < 0 {
|
||||
// A view that is being searched is already being read to the end.
|
||||
return
|
||||
}
|
||||
manager.ReadLinesAndWait(view.LinesHeight() + step)
|
||||
}
|
||||
|
||||
// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a
|
||||
// selection to sit on, from what it is showing so far. known is false while a render
|
||||
// still being read leaves the question open.
|
||||
func (gui *Gui) diffPaneHasSomethingToSelect(
|
||||
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
|
||||
) (bool, bool) {
|
||||
if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff {
|
||||
// Under a panel that shows no diff there is nothing to select whatever the pane
|
||||
// ends up holding, so this needs no content to answer. Answering it now matters,
|
||||
// because a render may never reach an end. The rest of a long commit log is read
|
||||
// only as far as the user scrolls, and until then the pane would go on showing
|
||||
// the selection it was left with under the panel before.
|
||||
return false, true
|
||||
}
|
||||
|
||||
if !contentIsComplete && mainContext.HasSelectableContent() {
|
||||
// This render has already found something to select, and its content only grows
|
||||
// from here, so there is nothing to ask again — nor to read the diff for. An
|
||||
// answer the render before it gave has been dropped by now (see
|
||||
// dropAnAnswerAboutAnotherRender), so this really is about the content in hand.
|
||||
return true, true
|
||||
}
|
||||
|
||||
hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view)
|
||||
|
||||
// One change line among those read settles it. Finding none in a render that is
|
||||
// still going may only mean the changes are in the part still to come. A commit's
|
||||
// diff opens with a diffstat, and for a commit touching hundreds of files that runs
|
||||
// well past the screenful the first paint reveals, so that answer waits.
|
||||
return hasChangeLines, contentIsComplete || hasChangeLines
|
||||
}
|
||||
|
||||
// mainContextForView returns the context of the main pane the given view is, or nil for
|
||||
// any other view.
|
||||
func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
|
||||
switch view {
|
||||
case gui.Views.Main:
|
||||
return gui.State.Contexts.Normal
|
||||
case gui.Views.Secondary:
|
||||
return gui.State.Contexts.NormalSecondary
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) setMainPanes(panes types.MainPanes) {
|
||||
gui.State.MainPanes = panes
|
||||
|
||||
// The label for the key that focuses the main view belongs on the pane that key
|
||||
// focuses, which is the secondary one while it is the only one shown.
|
||||
if panes == types.SecondaryPaneOnly {
|
||||
gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary)
|
||||
} else {
|
||||
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
|
||||
}
|
||||
}
|
||||
|
||||
// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and
|
||||
// takes it off the other one, so that only the pane the key focuses wears it.
|
||||
func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) {
|
||||
gui.Views.Main.TitlePrefix = ""
|
||||
gui.Views.Secondary.TitlePrefix = ""
|
||||
view.TitlePrefix = gui.focusMainViewJumpLabel
|
||||
}
|
||||
|
||||
// reApplySearch runs a search the view holds again over the content a render has just
|
||||
|
||||
@@ -36,6 +36,16 @@ type renderSpec struct {
|
||||
// user has configured. The renderer lays its rendering out to the width of the
|
||||
// view, which only the layout settles, so the task is created after it.
|
||||
func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
|
||||
// Ask whatever renders the diff to state, in an OSC 1717 record per line,
|
||||
// which line of which file it is rendering. This lets us act on the line the
|
||||
// user is pointing at even when the rendering no longer looks like a diff.
|
||||
// The variable names the protocol versions we understand, and a renderer
|
||||
// that doesn't understand it ignores it, so we can set it always. It has to
|
||||
// be set before the plain path below, since on that path git renders the
|
||||
// diff itself, and git speaks the protocol too, for its word-diff formats,
|
||||
// whose markup we could not otherwise resolve.
|
||||
cmd.Env = append(cmd.Env, "OSC1717=V1")
|
||||
|
||||
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
|
||||
// If we're not using a custom diff renderer, then we don't need to use a pty
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
|
||||
@@ -91,6 +91,7 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -109,6 +110,7 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -127,6 +129,7 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -154,10 +157,20 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
func() {
|
||||
// As the task reads more lines, the only thing that changes is the
|
||||
// view's content (and its scrollbar); the window layout doesn't. So a
|
||||
// content-only render is enough, and it's much cheaper than a full
|
||||
// layout-and-redraw on every read - which matters a lot when reading
|
||||
// a long diff, where reads happen repeatedly as the user scrolls.
|
||||
gui.renderContentOnly()
|
||||
// content-only render is enough — it skips the layout pass and redraws
|
||||
// only the cells that differ — and it's much cheaper than a full
|
||||
// layout-and-redraw on every read, which matters a lot when reading a
|
||||
// long diff, where reads happen repeatedly as the user scrolls.
|
||||
//
|
||||
// What this draws is more of the content than the pane held a moment
|
||||
// ago, so it is also where what is drawn over that content is worked
|
||||
// out again. The screenful the first paint reveals may not be enough
|
||||
// to say whether there is anything to select, and for a diff that
|
||||
// opens with a long diffstat it isn't.
|
||||
gui.c.OnUIThreadContentOnly(func() error {
|
||||
gui.updateDiffSelectionVisibility(view, false)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
func() {
|
||||
// The content is fully loaded now, so let the scrollbar track it
|
||||
@@ -174,13 +187,22 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
view.SetOrigin(0, newOriginY)
|
||||
}
|
||||
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.clampDiffSelectionToContent(view)
|
||||
gui.reApplySearch(view)
|
||||
},
|
||||
func() {
|
||||
view.SetOrigin(0, 0)
|
||||
},
|
||||
view.BeginOffscreenRender,
|
||||
view.SwapInOffscreenRender,
|
||||
func() {
|
||||
view.SwapInOffscreenRender()
|
||||
|
||||
// The content the pane is being given is on display from here on, so
|
||||
// what is drawn over it is settled against that content rather than
|
||||
// against the render before it.
|
||||
gui.updateDiffSelectionVisibility(view, false)
|
||||
},
|
||||
func() gocui.Task {
|
||||
// A background task: rendering content into a view is display
|
||||
// work, not lazygit driving a git operation, so it must not
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ type IBaseContext interface {
|
||||
// true if the context holds something for a selection to sit on. Contexts that
|
||||
// don't show a selection at all say false, and so do lists with nothing in them.
|
||||
HasSelectableContent() bool
|
||||
SetHasSelectableContent(bool)
|
||||
|
||||
// the total height of the content that the view is currently showing
|
||||
TotalContentHeight() int
|
||||
@@ -102,12 +103,13 @@ type IBaseContext interface {
|
||||
// that the generic ListController can be specialized by view-specific controllers.
|
||||
// We'll need to think of a better way to do this.
|
||||
AddOnDoubleClickFn(func() error)
|
||||
// Likewise for the focused main view: we need this to communicate between a
|
||||
// side panel controller and the focused main view controller.
|
||||
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
|
||||
// Adding on to the above, this is so that a list-specific handler can register
|
||||
// a hook for doing additional click handling
|
||||
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
|
||||
// Likewise for the focused main view, which acts on the diff of whichever panel
|
||||
// is beneath it and so has to reach that panel's controller. nil for a panel
|
||||
// that shows no diff.
|
||||
AddFocusedMainViewDiffSource(FocusedMainViewDiffSource)
|
||||
|
||||
AddOnRenderToMainFn(func())
|
||||
AddOnFocusFn(func(OnFocusOpts))
|
||||
@@ -180,6 +182,63 @@ type DiffableContext interface {
|
||||
RefForAdjustingLineNumberInDiff() string
|
||||
}
|
||||
|
||||
// DiffMainViewContext is implemented by the side panel contexts whose focused
|
||||
// main view shows a unified diff — files, local commits, sub-commits, reflog,
|
||||
// stash, and commit files — as opposed to a commit log or other non-diff content
|
||||
// (branches, tags, status, …). It is distinct from DiffableContext, which is
|
||||
// about producing a diff between two refs for the diff menu. The focused main
|
||||
// view shows a selection only for a context that implements this: a selection is
|
||||
// only meaningful where there are diff lines to act on (edit one, copy some, jump
|
||||
// by hunk or file). The returned type additionally classifies what acting on that
|
||||
// selection means.
|
||||
type DiffMainViewContext interface {
|
||||
Context
|
||||
|
||||
GetDiffMainViewType() DiffMainViewType
|
||||
}
|
||||
|
||||
// DiffMainViewType classifies what the focused main view's diff belongs to, which
|
||||
// decides what acting on a selection in it means.
|
||||
type DiffMainViewType int
|
||||
|
||||
const (
|
||||
// DiffMainViewTypeNone: the main view holds no diff, so there is nothing to
|
||||
// select. A side panel that doesn't implement DiffMainViewContext counts as
|
||||
// this; no panel returns it itself.
|
||||
DiffMainViewTypeNone DiffMainViewType = iota
|
||||
// DiffMainViewTypeStaging: the diff is the working tree's, so the selection can
|
||||
// be staged or unstaged (the files panel).
|
||||
DiffMainViewTypeStaging
|
||||
// DiffMainViewTypePatchBuilding: the diff belongs to a commit, so the selection
|
||||
// can be taken into a custom patch (the commit files / commits / sub-commits /
|
||||
// reflog / stash panels).
|
||||
DiffMainViewTypePatchBuilding
|
||||
)
|
||||
|
||||
// DiffPaneContext is one of the two panes the main section can show, as the thing
|
||||
// that holds a diff with a selection in it. The panels that act on such a selection
|
||||
// are handed the pane it was made in, and speak to it through this.
|
||||
type DiffPaneContext interface {
|
||||
Context
|
||||
|
||||
DiffSelectState() *DiffSelectState
|
||||
}
|
||||
|
||||
// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it
|
||||
// renders into the focused main view: the diff of the given files as git writes it,
|
||||
// with no colour and no diff renderer in the way. What the main view shows is that
|
||||
// same diff after a renderer has had it, which may have restructured, reordered or
|
||||
// dropped parts of it — so anything that needs the diff itself, rather than a picture
|
||||
// of it, asks the panel that produced it.
|
||||
//
|
||||
// paths are repo-relative, and are asked for rather than assumed so that a few lines
|
||||
// of a commit's diff can be had without fetching the whole thing. pane says which of
|
||||
// the two main panes is asking, since a panel can show a different diff in each — the
|
||||
// files panel shows the unstaged changes in one and the staged ones in the other.
|
||||
type FocusedMainViewDiffSource interface {
|
||||
PlainDiff(pane DiffPaneContext, paths []string) string
|
||||
}
|
||||
|
||||
type IListContext interface {
|
||||
Context
|
||||
|
||||
@@ -276,9 +335,10 @@ type HasKeybindings interface {
|
||||
// decides not to do anything with the click.
|
||||
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
|
||||
|
||||
// Implement this in a side-panel controller to get called when there's a click in the main view
|
||||
// that belongs to your panel while the main view is already focused.
|
||||
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
|
||||
// Implement this in a side-panel controller to hand out the diff behind what your
|
||||
// panel renders into the focused main view, for the commands that act on a
|
||||
// selection in it. nil for a controller whose panel shows no diff.
|
||||
GetFocusedMainViewDiffSource() FocusedMainViewDiffSource
|
||||
}
|
||||
|
||||
type IController interface {
|
||||
@@ -339,6 +399,8 @@ type IContextMgr interface {
|
||||
CurrentSide() Context
|
||||
CurrentPopup() []Context
|
||||
NextInStack(context Context) Context
|
||||
IsInStack(context Context) bool
|
||||
UpdateSelectionHighlights()
|
||||
IsCurrent(c Context) bool
|
||||
IsCurrentOrParent(c Context) bool
|
||||
ForEach(func(Context))
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package types
|
||||
|
||||
// DiffLineType classifies a row of a rendered diff.
|
||||
type DiffLineType int
|
||||
|
||||
const (
|
||||
DiffLineFileHeader DiffLineType = iota
|
||||
DiffLineHunkHeader
|
||||
DiffLineContext
|
||||
DiffLineAdded
|
||||
DiffLineDeleted
|
||||
// DiffLineOther is anything that isn't one of the above, e.g. the
|
||||
// "\ No newline at end of file" marker.
|
||||
DiffLineOther
|
||||
)
|
||||
|
||||
// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch
|
||||
// it was rendered from: which file the row belongs to, what kind of row it is,
|
||||
// and where the line sits in the old and new versions of that file. This lets us
|
||||
// act on the line the user is pointing at in a diff view: stage it, open it in an
|
||||
// editor, keep the cursor on it across a re-render. The rendered text alone tells
|
||||
// us none of that.
|
||||
type DiffLineInfo struct {
|
||||
// Path is the absolute path of the file the line belongs to.
|
||||
Path string
|
||||
Type DiffLineType
|
||||
// NewLine is the line's position in the new version of the file. Set for all
|
||||
// content lines (for a deletion it is the position the deletion sits at) and
|
||||
// for hunk headers (the first line of the hunk they head).
|
||||
NewLine int
|
||||
// OldLine is the line's position in the old version of the file. Set only
|
||||
// for deletions, which are the only rows that need it: two consecutive
|
||||
// deletions share a new-file position and differ only here.
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// IsChange reports whether the row is an added or deleted line, as opposed to a
|
||||
// context line or a header. It mirrors patch.PatchLine.IsChange: those are the
|
||||
// rows a patch is built from, and the rows navigation moves between.
|
||||
func (self DiffLineInfo) IsChange() bool {
|
||||
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
|
||||
}
|
||||
|
||||
// IsContent reports whether the row is a line of the file itself — a change or a
|
||||
// context line — as opposed to a header or a marker. Those are the rows that have a
|
||||
// position in the file, and so can be looked for in another rendering of the same
|
||||
// diff, or in the diff itself.
|
||||
func (self DiffLineInfo) IsContent() bool {
|
||||
return self.IsChange() || self.Type == DiffLineContext
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package types
|
||||
|
||||
// DiffSelectMode is how the focused main view's diff selection extends from the
|
||||
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
|
||||
// around the cursor.
|
||||
type DiffSelectMode int
|
||||
|
||||
const (
|
||||
DiffSelectModeLine DiffSelectMode = iota
|
||||
DiffSelectModeRange
|
||||
DiffSelectModeHunk
|
||||
)
|
||||
|
||||
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
|
||||
// selected line and the range anchor themselves live in the gocui view (its cursor
|
||||
// and range-select start), so only the mode lives here. It's on the context rather
|
||||
// than on the controller because the controller that drives the selection, the
|
||||
// controller that establishes it on focus, and the pane-toggle that seeds it on the
|
||||
// other pane all reach the pane through its context.
|
||||
type DiffSelectState struct {
|
||||
Mode DiffSelectMode
|
||||
// When a range is sticky, moving the cursor without holding shift extends the
|
||||
// range; otherwise it collapses the range back to a single line.
|
||||
RangeIsSticky bool
|
||||
// Whether hunk mode was turned on by the user rather than being the configured
|
||||
// default. This decides whether escape leaves hunk mode or leaves the view.
|
||||
UserEnabledHunkMode bool
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -133,14 +133,6 @@ func (gui *Gui) render() {
|
||||
gui.c.OnUIThread(func() error { return nil })
|
||||
}
|
||||
|
||||
// renderContentOnly triggers a re-render that skips the layout pass and only
|
||||
// redraws the views whose content changed (relying on tcell's cell-level dirty
|
||||
// tracking to emit just the cells that actually differ). Use it when only a
|
||||
// view's content changed, not the window layout.
|
||||
func (gui *Gui) renderContentOnly() {
|
||||
gui.c.OnUIThreadContentOnly(func() error { return nil })
|
||||
}
|
||||
|
||||
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
|
||||
// if the context's view is set to another context we do nothing.
|
||||
// if the context's view is the current view we trigger a focus; re-selecting the current item.
|
||||
@@ -154,7 +146,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
|
||||
|
||||
// The render may have given the context its first item, or taken its last one
|
||||
// away, which decides whether its view draws a selection at all.
|
||||
gui.State.ContextMgr.updateSelectionHighlights()
|
||||
gui.State.ContextMgr.UpdateSelectionHighlights()
|
||||
|
||||
if gui.currentViewName() == c.GetInputViewName() {
|
||||
c.HandleFocus(opts)
|
||||
|
||||
+12
-3
@@ -168,6 +168,15 @@ func (gui *Gui) createAllViews() error {
|
||||
|
||||
gui.Views.Snake.FgColor = gocui.ColorGreen
|
||||
|
||||
// The main views show diffs, whose own colors say what each line is: which side of
|
||||
// the diff it's on, and often its syntax highlighting too. A selection painted
|
||||
// across the whole line takes those colors over, which for a whole selected hunk
|
||||
// leaves one unreadable block; so mark the selection with a narrow bar at the left
|
||||
// edge instead, and leave the rest of the line to the diff. Two columns, enough to
|
||||
// read as a marker rather than as an artefact.
|
||||
gui.Views.Main.SelectedLineColorWidth = 2
|
||||
gui.Views.Secondary.SelectedLineColorWidth = 2
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -274,11 +283,11 @@ func (gui *Gui) configureViewProperties() {
|
||||
}
|
||||
}
|
||||
|
||||
gui.focusMainViewJumpLabel = ""
|
||||
if gui.c.UserConfig().Gui.ShowPanelJumps {
|
||||
gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
|
||||
} else {
|
||||
gui.Views.Main.TitlePrefix = ""
|
||||
gui.focusMainViewJumpLabel = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView)
|
||||
}
|
||||
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
|
||||
|
||||
// Index the tab strips by view so we can both set them on views that are
|
||||
// part of a multi-tab panel and clear them on views that no longer are
|
||||
|
||||
@@ -310,6 +310,7 @@ type TranslationSet struct {
|
||||
DiscardSelectionTooltip string
|
||||
ToggleSelectHunk string
|
||||
SelectHunk string
|
||||
NothingToSelectInDiff string
|
||||
SelectLineByLine string
|
||||
ToggleSelectHunkTooltip string
|
||||
HunkStagingHint string
|
||||
@@ -410,6 +411,8 @@ type TranslationSet struct {
|
||||
AskQuestion string
|
||||
PrevHunk string
|
||||
NextHunk string
|
||||
PrevFileInDiff string
|
||||
NextFileInDiff string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -733,6 +736,8 @@ type TranslationSet struct {
|
||||
CopyPathToClipboard string
|
||||
CommitPrefixPatternError string
|
||||
CopySelectedTextToClipboard string
|
||||
SelectedDiffLinesCopiedToast string
|
||||
SelectionNotFoundInDiffToast string
|
||||
NoFilesStagedTitle string
|
||||
NoFilesStagedPrompt string
|
||||
BranchNotFoundTitle string
|
||||
@@ -1493,6 +1498,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
ToggleRangeSelect: "Toggle range select",
|
||||
DismissRangeSelect: "Dismiss range select",
|
||||
ToggleSelectHunk: "Toggle hunk selection",
|
||||
NothingToSelectInDiff: "There is nothing to select here",
|
||||
SelectHunk: "Select hunks",
|
||||
SelectLineByLine: "Select line-by-line",
|
||||
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
|
||||
@@ -1598,6 +1604,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AskQuestion: "Ask Question",
|
||||
PrevHunk: "Go to previous hunk",
|
||||
NextHunk: "Go to next hunk",
|
||||
PrevFileInDiff: "Go to previous file",
|
||||
NextFileInDiff: "Go to next file",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
@@ -1919,6 +1927,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
CopyTagToClipboard: "Copy tag to clipboard",
|
||||
CopyPathToClipboard: "Copy path to clipboard",
|
||||
CopySelectedTextToClipboard: "Copy selected text to clipboard",
|
||||
SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard",
|
||||
SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff",
|
||||
CommitPrefixPatternError: "Error in commitPrefix pattern",
|
||||
NoFilesStagedTitle: "No files staged",
|
||||
NoFilesStagedPrompt: "You have not staged any files. Commit all files?",
|
||||
|
||||
@@ -88,6 +88,17 @@ func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// TitlePrefix asserts on the label a view wears in front of its title, which is the
|
||||
// key that jumps to it.
|
||||
func (self *ViewDriver) TitlePrefix(expected *TextMatcher) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
actual := self.getView().TitlePrefix
|
||||
return expected.context(fmt.Sprintf("%s title prefix", self.context)).test(actual)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) Clear() *ViewDriver {
|
||||
// clearing multiple times in case there's multiple lines
|
||||
// (the clear button only clears a single line at a time)
|
||||
@@ -141,6 +152,24 @@ func (self *ViewDriver) VisibleLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
return self.assertLines(originY, matchers...)
|
||||
}
|
||||
|
||||
// Asserts on the line the view shows at the top of its viewport, i.e. on where the
|
||||
// view is scrolled to. It is a view line, so a wrapped line above it doesn't throw the
|
||||
// count off.
|
||||
func (self *ViewDriver) TopVisibleLine(matcher *TextMatcher) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
view := self.getView()
|
||||
lines := view.ViewBufferLines()
|
||||
originY := view.OriginY()
|
||||
if originY >= len(lines) {
|
||||
return false, fmt.Sprintf("%s: the view is scrolled to line %d, but it has only %d lines",
|
||||
self.context, originY, len(lines))
|
||||
}
|
||||
return matcher.context(fmt.Sprintf("%s top visible line", self.context)).test(lines[originY])
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that somewhere in the view there are consecutive lines matching the given matchers.
|
||||
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
self.validateMatchersPassed(matchers)
|
||||
@@ -249,6 +278,21 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// SelectedViewLineRange asserts which view lines the selection covers. View lines
|
||||
// count the wrapped segments a line is drawn as, so this can say whether a selection
|
||||
// covers a wrapped line to its end; SelectedLines, which reports the lines of the
|
||||
// content, cannot.
|
||||
func (self *ViewDriver) SelectedViewLineRange(first int, last int) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
actualFirst, actualLast := self.getSelectedRange()
|
||||
return actualFirst == first && actualLast == last,
|
||||
fmt.Sprintf("%s: Expected view lines %d-%d to be selected, but %d-%d were.",
|
||||
self.context, first, last, actualFirst, actualLast)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) {
|
||||
if len(matchers) < 1 {
|
||||
self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()")
|
||||
|
||||
@@ -54,6 +54,6 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Contains("M file").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
|
||||
t.Views().Secondary().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Focus().
|
||||
SelectedLine(Contains("file1"))
|
||||
|
||||
t.Views().Main().Content(Contains("+second line"))
|
||||
// The patch was applied to the index, so the file's changes are all staged.
|
||||
t.Views().Secondary().Content(Contains("+second line"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiffRendererMetadata = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A diff renderer is told that we understand the OSC 1717 metadata protocol, and the records it emits don't show up in the rendered diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// A fake conforming renderer: it announces the protocol with a
|
||||
// version-only record, reports the protocol versions it was offered, and
|
||||
// then passes the diff through with a per-line record in front of every
|
||||
// line.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `printf '\033]1717;1\007'; ` +
|
||||
`printf 'OFFERED:%s\n' "$OSC1717"; ` +
|
||||
`while IFS= read -r line; do printf '\033]1717;1;c;1;;file1\007%s\n' "$line"; done`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("one").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
// The renderer was offered the protocol version we understand.
|
||||
Content(Contains("OFFERED:V1")).
|
||||
// Its records are escape sequences, so none of them reaches the
|
||||
// screen; the diff reads exactly as the renderer wrote it.
|
||||
ContainsLines(
|
||||
Equals("diff --git a/file1 b/file1"),
|
||||
Contains("new file mode"),
|
||||
Contains("index "),
|
||||
Equals("--- /dev/null"),
|
||||
Equals("+++ b/file1"),
|
||||
Equals("@@ -0,0 +1,2 @@"),
|
||||
Equals("+one"),
|
||||
Equals("+two"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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,40 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ClickSelectsDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Clicking a line of the main view's diff focuses the view and selects that line",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused()
|
||||
|
||||
// The click both focuses the view and points at a line, so that line is selected
|
||||
// rather than the first change.
|
||||
t.Views().Main().
|
||||
Click(0, 4).
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
SelectedLines(
|
||||
Contains("@@ -1,5 +1,5 @@"),
|
||||
).
|
||||
// A click in the already-focused view moves the selection to the clicked line.
|
||||
Click(0, 7).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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,43 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DragRangeWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Keep scrolling while dragging a range selection at the edge of the focused main view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 120,
|
||||
Height: 30,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
fileContent := "base\n"
|
||||
shell.CreateFileAndAdd("file1", fileContent)
|
||||
shell.Commit("one")
|
||||
for i := 1; i <= 40; i++ {
|
||||
fileContent += fmt.Sprintf("line %d\n", i)
|
||||
}
|
||||
shell.UpdateFile("file1", fileContent)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// The diff is longer than the view, so holding the pointer at the bottom edge has
|
||||
// to keep scrolling and extending the selection rather than stopping there.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
ClickAndHold(1, 6).
|
||||
MouseMoveToBottom(1).
|
||||
OriginYAtLeast(3).
|
||||
SelectedLineIdxAtLeast(9).
|
||||
MouseRelease()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DragSelectsDiffLineRange = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Dragging in the main view's diff selects the range from the line the drag started on",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Hunk mode is on, so the mouse-down alone selects the whole block; the drag
|
||||
// anchors the range where the mouse went down instead, one line at a time.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("-four"),
|
||||
Contains("+THREE"),
|
||||
Contains("+FOUR"),
|
||||
).
|
||||
ClickAndHold(0, 8).
|
||||
MouseMove(0, 9).
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
MouseMove(0, 10).
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
Contains("+THREE"),
|
||||
Contains("+FOUR"),
|
||||
).
|
||||
MouseRelease().
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
Contains("+THREE"),
|
||||
Contains("+FOUR"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var EditSelectedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Open the selected line of the main view's diff in the editor, at that line of the file",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
|
||||
config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// The addition is the third line of the file as it now stands.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("+THREE")).
|
||||
Press(keys.Universal.Edit)
|
||||
|
||||
// The editor is pointed at the file by absolute path.
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
|
||||
|
||||
// A file header points at the file rather than at a line in it, so it opens the
|
||||
// file with no line to jump to.
|
||||
t.Views().Main().
|
||||
NavigateToLine(Contains("diff --git a/file1 b/file1")).
|
||||
Press(keys.Universal.Edit)
|
||||
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/file1\n"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var EscapeDismissesSelection = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Escape gives up a range selection, then hunk mode, before leaving the focused main view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\nTWO\nTHREE\nfour\nfive\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// A sticky range: escape collapses it to the cursor line rather than leaving.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("-three"),
|
||||
).
|
||||
Press(keys.Universal.Return).
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// Hunk mode the user asked for: escape goes back to line-by-line.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("-three"),
|
||||
Contains("+TWO"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Press(keys.Universal.Return).
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
// With nothing left to give up, escape leaves.
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FileNavigationScrollsToTheTop = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Going to a file of a diff brings it to the top of the view, unless it is on screen already",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 20,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// More context than fits on screen, so that a long file's change is further
|
||||
// down than a screenful from the header naming the file.
|
||||
config.GetUserConfig().Git.DiffContextSize = 30
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
long := strings.Join(lines, "\n") + "\n"
|
||||
longChanged := strings.Replace(long, "line100", "LINE100", 1)
|
||||
|
||||
// Two long files with two short ones between them: the short ones are on screen
|
||||
// together, and there is enough diff below them to scroll to.
|
||||
shell.CreateFileAndAdd("aaa.txt", long)
|
||||
shell.CreateFileAndAdd("bbb.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ccc.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ddd.txt", long)
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("aaa.txt", longChanged)
|
||||
shell.UpdateFileAndAdd("bbb.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ccc.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ddd.txt", longChanged)
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// A file below the viewport becomes the top of it, so that as much of the file
|
||||
// as possible is on screen.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/bbb.txt b/bbb.txt"),
|
||||
).
|
||||
// The next file is on screen already, so the view stays where it is and
|
||||
// only the selection moves.
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/ccc.txt b/ccc.txt"),
|
||||
).
|
||||
// Going back to a file above the viewport brings that one to the top.
|
||||
Press(keys.Main.PrevFile).
|
||||
Press(keys.Main.PrevFile).
|
||||
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/aaa.txt b/aaa.txt"),
|
||||
).
|
||||
// In hunk mode the selection is the file's first change rather than the row
|
||||
// the file begins at, and with this much context that change is further down
|
||||
// than a screenful. The selection has to be on screen, so the alignment gives
|
||||
// way and the selection is scrolled into view as any other jump's is.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("-line100"),
|
||||
Contains("+LINE100"),
|
||||
).
|
||||
SelectedLineIsVisible()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var HideSelectionWhenChangesVanish = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The main view's selection disappears along with the changes it was on",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
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().
|
||||
SelectionIsActive().
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
// Discarding the change leaves the main view with a placeholder to show, so the
|
||||
// selection that was on the change goes with it rather than lingering over the
|
||||
// message.
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Discard changes")).
|
||||
Select(Contains("Discard all changes")).
|
||||
Confirm()
|
||||
}).
|
||||
IsEmpty()
|
||||
|
||||
t.Views().Main().
|
||||
Content(Contains("No changed files")).
|
||||
SelectionIsHidden()
|
||||
},
|
||||
})
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var KeepSelectionVisibleWhenDiffShrinks = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The selection stays on the content when a re-render leaves the diff with fewer lines than the selection was on",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: "cat"},
|
||||
// The same diff in fewer lines, as a renderer that collapses or elides
|
||||
// parts of it would give us: the addition at the end goes, and the hunk
|
||||
// header says so, since a diff that contradicts its own header can't be
|
||||
// read as one. (It has to read all of its input: one that exits early
|
||||
// leaves the render looking like it is still loading, which holds off the
|
||||
// clamping this test is about.)
|
||||
{Name: "shrinking", Command: `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nFIVE\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.GotoBottom).
|
||||
SelectedLines(
|
||||
Contains("+FIVE"),
|
||||
).
|
||||
Press(keys.Universal.CycleDiffRenderers).
|
||||
Tap(func() {
|
||||
t.ExpectToast(Equals("Diff renderer: shrinking (2 of 2)"))
|
||||
})
|
||||
|
||||
// The selection has nowhere to be but the last line there is. Asserting on the
|
||||
// index first waits for that to happen: the re-render and the clamp that
|
||||
// follows it are a frame apart, and reading the selected line's text in
|
||||
// between would be reading past the content.
|
||||
t.Views().Main().
|
||||
SelectionIsActive().
|
||||
SelectedLineIdx(10).
|
||||
SelectedLines(
|
||||
Contains("-five"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var NavigateByHunkAndFile = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump from hunk to hunk and from file to file in the focused main view of a commit's diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
|
||||
shell.UpdateFileAndAdd("file2", "one\ntwo\nTHREE\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("two").IsSelected(),
|
||||
Contains("one"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// Hunk navigation moves between change blocks. A hunk, in lazygit's sense,
|
||||
// is a run of changes bounded by context; one @@ hunk may hold several.
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// File navigation lands on the top of the next file's diff, which for a
|
||||
// parseable diff is its header.
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.PrevFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file1 b/file1"),
|
||||
).
|
||||
// A range that only grows while shift is held is a plain selection again once
|
||||
// we jump elsewhere, rather than stretching to wherever we land.
|
||||
Press(keys.Main.NextHunk).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
// A sticky range does stretch to it.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
Contains(" four"),
|
||||
Contains(" five"),
|
||||
Contains(" six"),
|
||||
Contains(" seven"),
|
||||
Contains(" eight"),
|
||||
Contains("-nine"),
|
||||
)
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user