mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-30 11:06:37 -04:00
Compare commits
@@ -226,11 +226,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
|
||||
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected 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 | |
|
||||
| `` c `` | Commit | Commit staged changes. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
| `` C `` | Commit changes using git editor | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Main panel (patch building)
|
||||
@@ -338,11 +346,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Edit file | Open file in external editor. |
|
||||
| `` <space> `` | Stage | Toggle selection staged / unstaged. |
|
||||
| `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected 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 | |
|
||||
| `` c `` | Commit | Commit staged changes. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
| `` C `` | Commit changes using git editor | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -195,11 +195,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` c `` | コミット | ステージされた変更をコミットします。 |
|
||||
| `` w `` | pre-commitフックなしで変更をコミット | |
|
||||
| `` C `` | Gitエディタを使用して変更をコミット | |
|
||||
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## タグ
|
||||
@@ -315,11 +323,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` E `` | ハンクを編集 | 選択したハンクを外部エディタで編集します。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` c `` | コミット | ステージされた変更をコミットします。 |
|
||||
| `` w `` | pre-commitフックなしで変更をコミット | |
|
||||
| `` C `` | Gitエディタを使用して変更をコミット | |
|
||||
| `` <ctrl+f> `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## メニュー
|
||||
|
||||
@@ -87,11 +87,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
|
||||
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## Stash
|
||||
@@ -172,11 +180,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` e `` | 파일 편집 | Open file in external editor. |
|
||||
| `` <space> `` | Staged 전환 | 선택한 행을 staged / unstaged |
|
||||
| `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
| `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## 메인 패널 (Patch Building)
|
||||
|
||||
@@ -234,11 +234,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected 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 | |
|
||||
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
|
||||
| `` w `` | Commit veranderingen zonder pre-commit hook | |
|
||||
| `` C `` | Commit veranderingen met de git editor | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Patch bouwen
|
||||
@@ -316,11 +324,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Edit hunk | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Copy selected 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 | |
|
||||
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
|
||||
| `` w `` | Commit veranderingen zonder pre-commit hook | |
|
||||
| `` C `` | Commit veranderingen met de git editor | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Staging
|
||||
|
||||
@@ -102,11 +102,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
|
||||
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
|
||||
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
|
||||
| `` <ctrl+o> `` | 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 | |
|
||||
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
|
||||
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
|
||||
| `` C `` | Zatwierdź zmiany używając edytora git | |
|
||||
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Drzewa pracy
|
||||
@@ -211,11 +219,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
|
||||
| `` <space> `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. |
|
||||
| `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. |
|
||||
| `` E `` | Edytuj fragment | Edytuj wybrany fragment w zewnętrznym edytorze. |
|
||||
| `` <ctrl+o> `` | 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 | |
|
||||
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
|
||||
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
|
||||
| `` C `` | Zatwierdź zmiany używając edytora git | |
|
||||
| `` <ctrl+f> `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Panel główny (scalanie)
|
||||
|
||||
@@ -238,11 +238,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
|
||||
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
|
||||
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
|
||||
| `` <ctrl+o> `` | 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 | |
|
||||
| `` c `` | Commit | Submeter mudanças em staging |
|
||||
| `` w `` | Fazer commit de alterações sem pré-commit | |
|
||||
| `` C `` | Enviar alteração usando um editor Git | |
|
||||
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Painel Principal (preparação)
|
||||
@@ -347,11 +355,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
|
||||
| `` <space> `` | Etapa | Ativar/desativar seleção em staged/unstaged |
|
||||
| `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. |
|
||||
| `` E `` | Editar hunk | Editar o local selecionado no editor externo. |
|
||||
| `` <ctrl+o> `` | 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 | |
|
||||
| `` c `` | Commit | Submeter mudanças em staging |
|
||||
| `` w `` | Fazer commit de alterações sem pré-commit | |
|
||||
| `` C `` | Enviar alteração usando um editor Git | |
|
||||
| `` <ctrl+f> `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado<br>Veja a documentação:<br><https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -77,11 +77,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Сохранить изменения | Commit staged changes. |
|
||||
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
|
||||
| `` C `` | Сохранить изменения с помощью редактора git | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Индексирование)
|
||||
@@ -116,11 +124,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` e `` | Редактировать файл | Open file in external editor. |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` E `` | Изменить эту часть | Edit selected hunk in external editor. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Сохранить изменения | Commit staged changes. |
|
||||
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
|
||||
| `` C `` | Сохранить изменения с помощью редактора git | |
|
||||
| `` <ctrl+f> `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Слияние)
|
||||
|
||||
@@ -289,11 +289,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` c `` | 提交变更 | 提交暂存文件 |
|
||||
| `` w `` | 提交变更而无需预先提交钩子 | |
|
||||
| `` C `` | 使用 Git 编辑器提交变更 | |
|
||||
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 正在合并
|
||||
@@ -344,11 +352,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` E `` | 编辑代码块 | 在外部编辑器中编辑选中的代码块 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` c `` | 提交变更 | 提交暂存文件 |
|
||||
| `` w `` | 提交变更而无需预先提交钩子 | |
|
||||
| `` C `` | 使用 Git 编辑器提交变更 | |
|
||||
| `` <ctrl+f> `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 状态
|
||||
|
||||
@@ -78,11 +78,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
@@ -366,11 +374,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | 選取未暫存的變更時,使用 `git reset` 捨棄變更。選取已暫存的變更時,取消暫存變更。 |
|
||||
| `` E `` | 編輯程式碼塊 | 在外部編輯器中編輯選中的程式碼塊。 |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
| `` C `` | 使用 git 編輯器提交變更 | |
|
||||
| `` <ctrl+f> `` | 尋找 fixup 的基礎提交 | 找出目前變更所依據的提交,以便 amend/fixup。這樣不必逐一檢視分支中的提交來找出要 amend/fixup 的提交。請見文件:<https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md> |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
||||
+9
-2
@@ -135,8 +135,15 @@ func NewGitCommandAux(
|
||||
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
|
||||
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
|
||||
patchBuilder := patch.NewPatchBuilder(cmn.Log,
|
||||
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain)
|
||||
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
|
||||
// A patch is built from git's own diff: what a diff renderer would make of it
|
||||
// is a picture of it, not something that can be applied.
|
||||
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
|
||||
},
|
||||
func() (string, error) {
|
||||
// Under lazygit's own temp dir, so that it honours the configured location
|
||||
// and is cleaned up with everything else when we exit.
|
||||
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
|
||||
})
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
@@ -240,12 +240,12 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
|
||||
return self.cmd.New(cmdArgs)
|
||||
}
|
||||
|
||||
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
|
||||
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string, mode DiffMode) *oscommands.CmdObj {
|
||||
cmdArgs := NewGitCmd("show").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
Arg("--submodule").
|
||||
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
|
||||
Arg("--color=" + mode.colorArg(self.diffRendererConfigManager)).
|
||||
Arg("--stat").
|
||||
Arg("--decorate").
|
||||
Arg("-p").
|
||||
|
||||
@@ -341,7 +341,7 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
}
|
||||
instance := buildCommitCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, runner: runner, repoPaths: &repoPaths})
|
||||
|
||||
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths).Run())
|
||||
assert.NoError(t, instance.ShowCmdObj("1234567890", s.filterPaths, DiffModeRendered).Run())
|
||||
runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,135 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/mgutz/str"
|
||||
)
|
||||
|
||||
// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol
|
||||
// emits before anything else, to announce that it does: a version-only record, with
|
||||
// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and
|
||||
// gocui's escape interpreter for how it is kept off the screen on a real render.
|
||||
const metadataHandshake = "\x1b]1717"
|
||||
|
||||
// probeWidth is the width a probe asks the renderer to lay out to. A probe has
|
||||
// nothing to render, so any width does, as long as it is one a renderer will
|
||||
// accept: a renderer that lays its rendering out in columns may well refuse a
|
||||
// width of zero, and would then never get as far as announcing itself.
|
||||
const probeWidth = 80
|
||||
|
||||
// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states
|
||||
// which line of which file it is rendering, by running it on empty input and looking
|
||||
// for the handshake. The answer decides whether a diff the renderer produced can be
|
||||
// acted on at all, or has to be replaced by git's own when the user wants to act on it
|
||||
// (see DiffLineHelper.MainViewDiffMode).
|
||||
//
|
||||
// Asking rather than watching a real render: the handshake is the renderer's first
|
||||
// output whatever the diff, so the answer is a property of the renderer, known before
|
||||
// we render anything — where watching would have to see a diff go by first, and would
|
||||
// be fooled by a diff with no lines to describe.
|
||||
//
|
||||
// No terminal is needed. git only invokes a stdin filter when it thinks it is talking
|
||||
// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is
|
||||
// set, so it can be run directly with empty input.
|
||||
func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool {
|
||||
manager := self.diffRendererConfigManager
|
||||
values := config.DiffRendererValues{Width: probeWidth, DiffContext: 3}
|
||||
|
||||
switch manager.GetDiffRendererType() {
|
||||
case config.DiffRendererType_StdinFilter:
|
||||
if command, err := manager.GetStdinFilterCommand(values); err == nil && command != "" {
|
||||
return self.probeEmitsMetadata(self.cmd.NewShell(command, ""))
|
||||
}
|
||||
case config.DiffRendererType_ExtDiff:
|
||||
// An empty command means git's own diff.external config, which picks a driver
|
||||
// per file through .gitattributes: there is no one renderer to ask, and a single
|
||||
// diff can be produced by several, so we take it that it says nothing.
|
||||
if command, err := manager.GetExternalDiffCommand(values); err == nil && command != "" {
|
||||
return self.externalDiffEmitsMetadata(command)
|
||||
}
|
||||
case config.DiffRendererType_RawGit:
|
||||
// git describes only the formats whose output can't be read back as a diff, and
|
||||
// asked with the renderer's own arguments it answers for exactly the format
|
||||
// those select: a handshake for a word diff, silence for a unified one. With no
|
||||
// arguments there is nothing to fall back to anyway, since this already is git's
|
||||
// own diff.
|
||||
if args := manager.GetRawGitArgs(); len(args) > 0 {
|
||||
return self.rawGitEmitsMetadata(args)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments.
|
||||
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
|
||||
oldPath, newPath, cleanup, ok := self.probeFiles()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return self.probeEmitsMetadata(self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Arg("--no-index").
|
||||
Arg(rawGitArgs...).
|
||||
Arg(oldPath, newPath).
|
||||
ToArgv(),
|
||||
))
|
||||
}
|
||||
|
||||
// externalDiffEmitsMetadata asks an external diff command, invoking it the way git
|
||||
// invokes one — with the seven positional arguments of git's diff.external convention —
|
||||
// over two empty files, so that it announces itself without having a diff to render.
|
||||
func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool {
|
||||
oldPath, newPath, cleanup, ok := self.probeFiles()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
args := append(str.ToArgv(externalDiffCommand),
|
||||
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
|
||||
return self.probeEmitsMetadata(self.cmd.New(args))
|
||||
}
|
||||
|
||||
// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup
|
||||
// that removes them. Empty, because what the probe wants is for the renderer to announce
|
||||
// itself, not for it to have anything to say.
|
||||
func (self *DiffCommands) probeFiles() (string, string, func(), bool) {
|
||||
tempDir := self.os.GetTempDir()
|
||||
|
||||
oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*")
|
||||
if err != nil {
|
||||
return "", "", nil, false
|
||||
}
|
||||
oldFile.Close()
|
||||
|
||||
newFile, err := os.CreateTemp(tempDir, "lazygit-probe-new-*")
|
||||
if err != nil {
|
||||
os.Remove(oldFile.Name())
|
||||
return "", "", nil, false
|
||||
}
|
||||
newFile.Close()
|
||||
|
||||
return oldFile.Name(), newFile.Name(), func() {
|
||||
os.Remove(oldFile.Name())
|
||||
os.Remove(newFile.Name())
|
||||
}, true
|
||||
}
|
||||
|
||||
func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool {
|
||||
cmdObj.AddEnvVars("OSC1717=V1")
|
||||
// A renderer may well object to being handed nothing to render. We want to know
|
||||
// whatever it said before objecting, and that is captured either way.
|
||||
output, _ := cmdObj.RunWithOutput()
|
||||
return strings.Contains(output, metadataHandshake)
|
||||
}
|
||||
|
||||
type DiffCommands struct {
|
||||
*GitCommon
|
||||
}
|
||||
@@ -18,19 +143,41 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
|
||||
|
||||
// This is for generating diffs to be shown in the UI (e.g. rendering a range
|
||||
// diff to the main view). It uses a custom diff renderer if one is configured.
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
Arg(diffArgs...).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv(),
|
||||
)
|
||||
}
|
||||
|
||||
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
|
||||
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
|
||||
// under the directory holding them. It goes through the same wiring as any other diff we
|
||||
// show, so the patch is rendered by whatever renders the rest of them, and git works out
|
||||
// how much context to give it.
|
||||
//
|
||||
// git's own path prefixes are suppressed because the trees are named a and b themselves,
|
||||
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
|
||||
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
NoLineEndingConversion().
|
||||
Arg("--no-index").
|
||||
Arg("--no-prefix").
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
Arg("a", "b").
|
||||
Dir(dir).
|
||||
ToArgv(),
|
||||
)
|
||||
}
|
||||
|
||||
// This is a basic generic diff command that can be used for any diff operation
|
||||
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
|
||||
// and does not use user configs such as ignore whitespace.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
)
|
||||
|
||||
// DiffMode says what a diff command's output is for. This decides whether the
|
||||
// configured diff renderer produces it, and whether it is coloured.
|
||||
type DiffMode int
|
||||
|
||||
const (
|
||||
// DiffModeRendered is the diff as the user has arranged for it to look: through the
|
||||
// diff renderer, with the renderer's own arguments and its preference about colour.
|
||||
DiffModeRendered DiffMode = iota
|
||||
// DiffModeRaw is git's own coloured diff, for showing a diff whose rendered form
|
||||
// couldn't be acted on.
|
||||
DiffModeRaw
|
||||
// DiffModePlain is git's own uncoloured diff, for building patches from and copying
|
||||
// text out of rather than for looking at.
|
||||
DiffModePlain
|
||||
)
|
||||
|
||||
// colorArg returns the value to pass to git's --color for this mode. Rendered output is
|
||||
// coloured however the renderer wants its input; a raw diff gets git's own colour, which
|
||||
// is the point of it; a plain one is for reading as text, not for looking at.
|
||||
func (self DiffMode) colorArg(diffRendererConfigManager *config.DiffRendererConfigManager) string {
|
||||
switch self {
|
||||
case DiffModeRendered:
|
||||
return diffRendererConfigManager.GetColorArg()
|
||||
case DiffModeRaw:
|
||||
return "always"
|
||||
default:
|
||||
return "never"
|
||||
}
|
||||
}
|
||||
@@ -123,16 +123,42 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
|
||||
// NoLineEndingConversion keeps git's line-ending machinery away from files that are not
|
||||
// a working tree's. The trees the custom patch is materialized into hold the bytes git
|
||||
// states the patch in, so a command over them has to read and write those bytes as they
|
||||
// are. On a machine that checks files out with CRLF, `git apply` writes the after tree
|
||||
// in that form while the before tree keeps the LF it was written with. git's own diff
|
||||
// converts both back, but it warns about a round trip through a working tree these
|
||||
// files never belong to, and an external diff renderer is handed the two files as they
|
||||
// stand, one line ending apart in every line.
|
||||
func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder {
|
||||
return self.
|
||||
// The setting that converts on most machines, and the one Git for Windows
|
||||
// installs itself with.
|
||||
Config("core.autocrlf=false").
|
||||
// An attributes file outside the repo can still mark the files as text. The
|
||||
// form to keep them in is then the form they are written in.
|
||||
Config("core.eol=lf").
|
||||
// An attribute naming CRLF outright overrides that, and git converts after all.
|
||||
// The warning it gives is about a checkout these files never have.
|
||||
Config("core.safecrlf=false")
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
|
||||
contextSize := userConfig.Git.DiffContextSize
|
||||
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
|
||||
return self.
|
||||
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
// Ignoring whitespace is about what the user wants to see, so it holds for a raw
|
||||
// diff as much as for a rendered one. Patches are built from a plain diff,
|
||||
// where a diff that leaves changes out would apply to nothing.
|
||||
ArgIf(mode != DiffModePlain && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
|
||||
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
|
||||
// The renderer's own arguments to git — a word diff, say — are part of the
|
||||
// rendering, so they go with it.
|
||||
ArgIf(mode == DiffModeRendered, diffRendererConfigManager.GetRawGitArgs()...)
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) ToArgv() []string {
|
||||
|
||||
@@ -2,13 +2,16 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/app/daemon"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
)
|
||||
|
||||
@@ -20,6 +23,10 @@ type PatchCommands struct {
|
||||
stash *StashCommands
|
||||
|
||||
PatchBuilder *patch.PatchBuilder
|
||||
|
||||
// The version of the patch the diff trees were last written for, so that they are
|
||||
// written again when, and only when, the patch has changed since.
|
||||
treesWrittenForGeneration int
|
||||
}
|
||||
|
||||
func NewPatchCommands(
|
||||
@@ -40,6 +47,88 @@ func NewPatchCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
|
||||
// longer describes the patch. Call it before rendering the patch, which is often — every
|
||||
// time the panel showing it re-renders — while the patch itself changes rarely.
|
||||
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
|
||||
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
|
||||
return nil
|
||||
}
|
||||
if err := self.WriteCustomPatchDiffTrees(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
|
||||
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
|
||||
// before the patch, `b` as it is after. Diffing those two trees against each other
|
||||
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
|
||||
// can then be rendered exactly as any other diff is — through a diff renderer of any
|
||||
// kind, and with git's own idea of how much context to show.
|
||||
//
|
||||
// The trees are named a and b so that the diff's paths, with git's own prefixes
|
||||
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
|
||||
// repo-relative paths.
|
||||
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
|
||||
dir := self.PatchBuilder.TempDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
before := filepath.Join(dir, "a")
|
||||
after := filepath.Join(dir, "b")
|
||||
for _, tree := range []string{before, after} {
|
||||
if err := os.RemoveAll(tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range self.PatchBuilder.FilesInPatch() {
|
||||
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
|
||||
// A file the patch adds has no content on the before side, so git has nothing to
|
||||
// show for it.
|
||||
added := err != nil
|
||||
|
||||
// The before side holds an added file as an empty file rather than not at all, so
|
||||
// that the diff pairs the two sides up and states the file's real path, instead of
|
||||
// reporting a file that only one of the trees has.
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
|
||||
lo.Ternary(added, "", content)); err != nil {
|
||||
return err
|
||||
}
|
||||
// The after side is seeded with the same content, for the patch to change; a file
|
||||
// the patch adds is left absent, for the patch to create.
|
||||
if !added {
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write added files as creations rather than as diffs against an empty file: the
|
||||
// patch is applied in one go, so a file it expects to be there already would make
|
||||
// the whole of it fail.
|
||||
patchText := self.PatchBuilder.PatchToApply(false, false)
|
||||
if strings.TrimSpace(patchText) == "" {
|
||||
// Nothing in the patch, so the two trees are alike and the diff is empty.
|
||||
return nil
|
||||
}
|
||||
patchFilePath, err := self.SaveTemporaryPatch(patchText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return self.cmd.New(NewGitCmd("apply").
|
||||
NoLineEndingConversion().
|
||||
Arg(patchFilePath).
|
||||
Dir(after).
|
||||
ToArgv()).Run()
|
||||
}
|
||||
|
||||
type ApplyPatchOpts struct {
|
||||
ThreeWay bool
|
||||
Cached bool
|
||||
|
||||
@@ -80,14 +80,14 @@ func (self *StashCommands) Hash(index int) (string, error) {
|
||||
return strings.Trim(hash, "\r\n"), err
|
||||
}
|
||||
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int, mode DiffMode) *oscommands.CmdObj {
|
||||
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
|
||||
cmdArgs := NewGitCmd("stash").Arg("show").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
Arg("-p").
|
||||
Arg("--stat").
|
||||
Arg("-u").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv()
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
}
|
||||
instance := buildStashCommands(commonDeps{userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
|
||||
|
||||
cmdStr := instance.ShowStashEntryCmdObj(s.index).Args()
|
||||
cmdStr := instance.ShowStashEntryCmdObj(s.index, DiffModeRendered).Args()
|
||||
assert.Equal(t, s.expected, cmdStr)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
|
||||
}
|
||||
|
||||
// WorktreeFileDiff returns the diff of a file
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, mode DiffMode, cached bool) string {
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, mode, cached, file.Names()).RunWithOutput()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -393,18 +393,13 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool,
|
||||
// in the working tree. node is the item they belong to; all it decides is
|
||||
// whether git has to compare against /dev/null, which is the case for a file
|
||||
// that isn't in the index yet.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, mode DiffMode, cached bool, paths []string) *oscommands.CmdObj {
|
||||
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
ArgIf(cached, "--cached").
|
||||
ArgIf(noIndex, "--no-index").
|
||||
Arg("--").
|
||||
@@ -420,25 +415,20 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
|
||||
// but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode.
|
||||
// For a renamed file, previousPath is the path it was renamed from (empty otherwise);
|
||||
// both paths must be passed to git for the rename to be detected.
|
||||
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) {
|
||||
func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, mode DiffMode) (string, error) {
|
||||
fileNames := []string{fileName}
|
||||
if previousPath != "" {
|
||||
fileNames = append(fileNames, previousPath)
|
||||
}
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, mode).RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, mode DiffMode) *oscommands.CmdObj {
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
Arg(from).
|
||||
Arg(to).
|
||||
ArgIf(reverse, "-R").
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
file *models.File
|
||||
plain bool
|
||||
mode DiffMode
|
||||
cached bool
|
||||
ignoreWhitespace bool
|
||||
contextSize uint64
|
||||
@@ -215,7 +215,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: false,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
@@ -230,7 +230,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: true,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
@@ -245,7 +245,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: true,
|
||||
mode: DiffModePlain,
|
||||
cached: false,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
@@ -260,7 +260,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: false,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: false,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
@@ -275,7 +275,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: false,
|
||||
ignoreWhitespace: true,
|
||||
contextSize: 3,
|
||||
@@ -290,7 +290,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: false,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 17,
|
||||
@@ -305,7 +305,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
HasStagedChanges: false,
|
||||
Tracked: true,
|
||||
},
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
cached: false,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
@@ -326,7 +326,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
}
|
||||
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
|
||||
result := instance.WorktreeFileDiff(s.file, s.plain, s.cached)
|
||||
result := instance.WorktreeFileDiff(s.file, s.mode, s.cached)
|
||||
assert.Equal(t, expectedResult, result)
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
@@ -341,7 +341,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
reverse bool
|
||||
fileName string
|
||||
previousPath string
|
||||
plain bool
|
||||
mode DiffMode
|
||||
ignoreWhitespace bool
|
||||
contextSize uint64
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
@@ -356,7 +356,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
to: "0987654321",
|
||||
reverse: false,
|
||||
fileName: "test.txt",
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
@@ -368,7 +368,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
to: "0987654321",
|
||||
reverse: false,
|
||||
fileName: "test.txt",
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 123,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
@@ -380,7 +380,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
to: "0987654321",
|
||||
reverse: false,
|
||||
fileName: "test.txt",
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
ignoreWhitespace: true,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
@@ -393,7 +393,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
reverse: false,
|
||||
fileName: "new.txt",
|
||||
previousPath: "old.txt",
|
||||
plain: false,
|
||||
mode: DiffModeRendered,
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
@@ -412,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths})
|
||||
|
||||
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain)
|
||||
result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.mode)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedResult, result)
|
||||
s.runner.CheckForMissingCalls()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/maps"
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -33,7 +35,7 @@ type fileInfo struct {
|
||||
}
|
||||
|
||||
type (
|
||||
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error)
|
||||
loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string) (string, error)
|
||||
)
|
||||
|
||||
// PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility
|
||||
@@ -60,12 +62,27 @@ type PatchBuilder struct {
|
||||
|
||||
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
|
||||
loadFileDiff loadFileDiffFunc
|
||||
|
||||
// newTempDir makes a directory for the current patch to be materialized into, as
|
||||
// two file trees that can be diffed against each other and so rendered like any
|
||||
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
|
||||
// patch's: made when one is started, removed when it is given up.
|
||||
newTempDir func() (string, error)
|
||||
tempDir string
|
||||
|
||||
// generation counts the changes made to the patch, so that whoever materializes it
|
||||
// can tell whether what they last built still describes it — and rebuild only then,
|
||||
// rather than on every render of it.
|
||||
generation int
|
||||
}
|
||||
|
||||
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
|
||||
func NewPatchBuilder(
|
||||
log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error),
|
||||
) *PatchBuilder {
|
||||
return &PatchBuilder{
|
||||
Log: log,
|
||||
loadFileDiff: loadFileDiff,
|
||||
newTempDir: newTempDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.makeTempDir()
|
||||
|
||||
p.To = to
|
||||
p.From = from
|
||||
p.reverse = reverse
|
||||
@@ -91,6 +111,89 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
|
||||
return p.fileInfoMap
|
||||
}
|
||||
|
||||
// TempDir is the directory the patch is materialized into for rendering, and "" when
|
||||
// there is none — no patch, or a directory we failed to make.
|
||||
func (p *PatchBuilder) TempDir() string {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
return p.tempDir
|
||||
}
|
||||
|
||||
// Generation says which version of the patch this is; see the field.
|
||||
func (p *PatchBuilder) Generation() int {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
return p.generation
|
||||
}
|
||||
|
||||
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
|
||||
// Only call this with the lock held.
|
||||
func (p *PatchBuilder) makeTempDir() {
|
||||
p.removeTempDir()
|
||||
if p.newTempDir == nil {
|
||||
return
|
||||
}
|
||||
dir, err := p.newTempDir()
|
||||
if err != nil {
|
||||
p.Log.Error(err)
|
||||
return
|
||||
}
|
||||
p.tempDir = dir
|
||||
}
|
||||
|
||||
// removeTempDir takes the patch's materialized form away with the patch. Only call this
|
||||
// with the lock held.
|
||||
func (p *PatchBuilder) removeTempDir() {
|
||||
if p.tempDir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(p.tempDir); err != nil {
|
||||
p.Log.Error(err)
|
||||
}
|
||||
p.tempDir = ""
|
||||
}
|
||||
|
||||
// PatchFile records what materializing the patch needs to know about one of its files:
|
||||
// where the patch expects to find it, and where its content before the patch comes from.
|
||||
type PatchFile struct {
|
||||
// Path is the name the patch knows the file by: for a renamed file, the name it had
|
||||
// before where the patch carries the rename, and the name it was renamed to where the
|
||||
// patch keeps only a content change and leaves the rename behind.
|
||||
Path string
|
||||
// ContentPath is where the file's content before the patch is to be found in the
|
||||
// commit the patch is built from — for a renamed file always the name it had there,
|
||||
// whatever the patch calls it.
|
||||
ContentPath string
|
||||
}
|
||||
|
||||
// FilesInPatch says which files the patch touches, in a stable order, and where each of
|
||||
// them comes from.
|
||||
func (p *PatchBuilder) FilesInPatch() []PatchFile {
|
||||
fileInfoMap := p.snapshotFileInfoMap()
|
||||
|
||||
filenames := maps.Keys(fileInfoMap)
|
||||
sort.Strings(filenames)
|
||||
|
||||
files := make([]PatchFile, 0, len(filenames))
|
||||
for _, filename := range filenames {
|
||||
info := fileInfoMap[filename]
|
||||
if info.mode == UNSELECTED {
|
||||
continue
|
||||
}
|
||||
file := PatchFile{Path: filename, ContentPath: filename}
|
||||
if info.previousPath != "" {
|
||||
file.ContentPath = info.previousPath
|
||||
if info.mode == WHOLE {
|
||||
file.Path = info.previousPath
|
||||
}
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
|
||||
var patch strings.Builder
|
||||
|
||||
@@ -135,6 +238,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.addFileWhole(info)
|
||||
|
||||
return nil
|
||||
@@ -146,6 +250,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.removeFile(info)
|
||||
|
||||
return nil
|
||||
@@ -162,7 +267,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
|
||||
return info, nil
|
||||
}
|
||||
|
||||
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
|
||||
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -182,6 +287,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
|
||||
|
||||
@@ -193,6 +299,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
|
||||
if len(info.includedLineIndices) == 0 {
|
||||
@@ -291,6 +398,104 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
|
||||
return info.mode
|
||||
}
|
||||
|
||||
// LineIdentity says which change line of a file is meant — the line number it has on
|
||||
// the side it belongs to, and whether it is a deletion — without reference to where
|
||||
// that line sits in the file's parsed diff.
|
||||
//
|
||||
// It is how a diff shown in the main view speaks about its lines: what a rendered row
|
||||
// resolves to is a line of a file, while the index of that line in the diff depends on
|
||||
// how much of the diff is being shown and in what order a renderer laid it out.
|
||||
type LineIdentity struct {
|
||||
LineNumber int
|
||||
IsDeletion bool
|
||||
}
|
||||
|
||||
// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An
|
||||
// addition is numbered in the new file and a deletion in the old one. Two consecutive
|
||||
// deletions share the one new-file position between them, and numbering them in the
|
||||
// old file keeps them apart.
|
||||
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
|
||||
byIdentity := map[LineIdentity]int{}
|
||||
for idx, line := range parsed.Lines() {
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
|
||||
case line.IsDeletion():
|
||||
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
|
||||
}
|
||||
}
|
||||
return byIdentity
|
||||
}
|
||||
|
||||
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
|
||||
// indices in it. A line that names no change line of the diff — a context line, or a
|
||||
// line that isn't in the diff at all — contributes nothing.
|
||||
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
|
||||
byIdentity := ChangeLineIndexByIdentity(parsed)
|
||||
indices := make([]int, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if idx, ok := byIdentity[line]; ok {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// PatchLineIndicesForLines maps change lines of filename to their indices in that
|
||||
// file's diff, which is what the patch is built in terms of.
|
||||
func (p *PatchBuilder) PatchLineIndicesForLines(
|
||||
filename string, previousPath string, lines []LineIdentity,
|
||||
) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, previousPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ChangeLineIndicesForLines(Parse(info.diff), lines), nil
|
||||
}
|
||||
|
||||
// IncludedLineIdentities says which change lines of filename are in the patch, as the
|
||||
// identities a diff of that file shown anywhere can be compared against. Empty for a
|
||||
// file that is no part of the patch.
|
||||
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
|
||||
info, ok := p.snapshotFileInfoMap()[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
|
||||
included := set.NewFromSlice(info.includedLineIndices)
|
||||
identities := []LineIdentity{}
|
||||
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
|
||||
if included.Includes(idx) {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
|
||||
// their indices in the file's diff and in the order the file has them.
|
||||
//
|
||||
// It is how a line of the patch as it is shown names the line of the diff it came from:
|
||||
// all that can be said about a line of the patch is which of the file's changes it is,
|
||||
// its line numbers being the patch's own — a patch that leaves an earlier addition out
|
||||
// numbers everything after it differently from the diff it was built from.
|
||||
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
|
||||
info, ok := p.snapshotFileInfoMap()[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
|
||||
included := set.NewFromSlice(info.includedLineIndices)
|
||||
indices := []int{}
|
||||
for idx, line := range Parse(info.diff).Lines() {
|
||||
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, previousPath)
|
||||
if err != nil {
|
||||
@@ -304,6 +509,9 @@ func (p *PatchBuilder) Reset() {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.removeTempDir()
|
||||
|
||||
p.To = ""
|
||||
p.fileInfoMap = map[string]*fileInfo{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
|
||||
// every file's diff is the given one.
|
||||
func newTestPatchBuilder(diff string) *PatchBuilder {
|
||||
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
|
||||
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
|
||||
return diff, nil
|
||||
},
|
||||
// Nothing here renders the patch, so it needs no directory to be
|
||||
// materialized into.
|
||||
nil)
|
||||
patchBuilder.Start("from", "to", false, true)
|
||||
return patchBuilder
|
||||
}
|
||||
|
||||
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
|
||||
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
|
||||
func TestPatchLineIndicesForLines(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
|
||||
}
|
||||
|
||||
// A renamed file's rename header makes its change lines sit further down the diff, and
|
||||
// its old-file line numbers are of the file under its previous name.
|
||||
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
|
||||
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{9, 10}, indices)
|
||||
}
|
||||
|
||||
func TestIncludedLineIdentities(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// A file no part of the patch has nothing included.
|
||||
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
|
||||
|
||||
// With only the deletion in, only its identity comes back.
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
|
||||
// With the addition in as well, both do.
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
}
|
||||
|
||||
func TestFilesInPatch(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// A file no part of the patch is no part of what the patch is materialized from.
|
||||
assert.Empty(t, patchBuilder.FilesInPatch())
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "filename", ContentPath: "filename"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
}
|
||||
|
||||
// A renamed file's content is under the name it had before whatever the patch calls the
|
||||
// file, and the patch calls it by the name it had before only where it carries the
|
||||
// rename — a partial selection has the rename stripped and names the file by the new one.
|
||||
func TestFilesInPatchOfARenamedFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9}))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "newname", ContentPath: "oldname"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname"))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "oldname", ContentPath: "oldname"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
}
|
||||
|
||||
// A file taken into the patch whole has every one of its change lines in it.
|
||||
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
}
|
||||
@@ -50,6 +50,22 @@ func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
|
||||
return GocuiEvent{Type: eventKey, Key: key}, &fired
|
||||
}
|
||||
|
||||
// runQueuedWork runs what the gui has queued for the following passes of the
|
||||
// event loop, which is where EndBlockingEvents leaves the replay of the keys it
|
||||
// buffered.
|
||||
func runQueuedWork(t *testing.T, g *Gui) {
|
||||
t.Helper()
|
||||
|
||||
for {
|
||||
ev, ok := g.userEvents.dequeue()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
assert.NoError(t, ev.f(g))
|
||||
ev.task.Done()
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
keyEvent, fired := setupKeyRecorder(t, g)
|
||||
@@ -64,12 +80,37 @@ func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
|
||||
|
||||
// Unblocking replays the buffered keys.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
// Unblocking queues the replay rather than dispatching from here.
|
||||
g.EndBlockingEvents()
|
||||
assert.Len(t, *fired, 1, "the replay must wait for the event loop")
|
||||
|
||||
runQueuedWork(t, g)
|
||||
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
|
||||
assert.Empty(t, g.bufferedKeyEvents)
|
||||
}
|
||||
|
||||
func TestBlockingEvents_KeysArrivingBeforeTheReplayGoBehindIt(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
keyEvent, fired := setupKeyRecorder(t, g)
|
||||
other := GocuiEvent{Type: eventKey, Key: NewKeyRune('y')}
|
||||
g.SetKeybinding("main", other.Key, func(*Gui, *View) error {
|
||||
*fired = append(*fired, 0)
|
||||
return nil
|
||||
})
|
||||
|
||||
g.BeginBlockingEvents()
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
g.EndBlockingEvents()
|
||||
|
||||
// A key pressed while the replay is still queued joins the end of the buffer:
|
||||
// dispatching it now would put it ahead of the keys buffered before it.
|
||||
assert.NoError(t, g.handleEvent(&other))
|
||||
assert.Empty(t, *fired)
|
||||
|
||||
runQueuedWork(t, g)
|
||||
assert.Equal(t, []int{1, 0}, *fired, "the keys should arrive in the order they were pressed")
|
||||
}
|
||||
|
||||
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
keyEvent, fired := setupKeyRecorder(t, g)
|
||||
@@ -79,11 +120,13 @@ func TestBlockingEvents_NestsWithCounter(t *testing.T) {
|
||||
assert.NoError(t, g.handleEvent(&keyEvent))
|
||||
|
||||
// The inner block ending still leaves us blocked: no replay yet.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
g.EndBlockingEvents()
|
||||
runQueuedWork(t, g)
|
||||
assert.Empty(t, *fired)
|
||||
|
||||
// Only the outermost block ending replays.
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
g.EndBlockingEvents()
|
||||
runQueuedWork(t, g)
|
||||
assert.Len(t, *fired, 1)
|
||||
}
|
||||
|
||||
@@ -94,5 +137,6 @@ func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
|
||||
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
|
||||
assert.NoError(t, g.handleEvent(&click))
|
||||
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
|
||||
assert.NoError(t, g.EndBlockingEvents())
|
||||
g.EndBlockingEvents()
|
||||
runQueuedWork(t, g)
|
||||
}
|
||||
|
||||
+36
-5
@@ -236,10 +236,13 @@ type Gui struct {
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
// dropped outright. It's a counter so blocking can nest. Both fields are
|
||||
// only touched on the UI thread. See BeginBlockingEvents.
|
||||
// dropped outright. It's a counter so blocking can nest. replayPending says
|
||||
// that the replay is queued but hasn't run yet, and input is withheld until
|
||||
// it has. All three fields are only touched on the UI thread. See
|
||||
// BeginBlockingEvents.
|
||||
blockInputCount int
|
||||
bufferedKeyEvents []GocuiEvent
|
||||
replayPending bool
|
||||
}
|
||||
|
||||
type NewGuiOpts struct {
|
||||
@@ -930,12 +933,33 @@ func (g *Gui) BeginBlockingEvents() {
|
||||
// normal dispatch path, so they act on the now-current context (a key whose
|
||||
// binding no longer exists is simply ignored, just as if it had been pressed
|
||||
// now). Must be called on the UI thread.
|
||||
func (g *Gui) EndBlockingEvents() error {
|
||||
//
|
||||
// The replay is queued rather than run here, so that the buffered keys arrive on
|
||||
// a later pass of the event loop, as they would have if the user had pressed them
|
||||
// then. Running them here dispatches them from the middle of whatever the caller
|
||||
// was doing. If a caller ends the block partway through updating the screen, a
|
||||
// handler then acts on state the caller has yet to finish writing.
|
||||
func (g *Gui) EndBlockingEvents() {
|
||||
g.blockInputCount--
|
||||
if g.blockInputCount > 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// Input stays withheld until the replay has run. Gui events are dispatched in
|
||||
// preference to queued work (see processRemainingEvents), so a key pressed
|
||||
// before the replay gets its turn would otherwise be handled ahead of the keys
|
||||
// buffered before it.
|
||||
g.replayPending = true
|
||||
g.Update(func(*Gui) error { return g.replayBufferedKeys() })
|
||||
}
|
||||
|
||||
// replayBufferedKeys dispatches the keys withheld while input was blocked, and
|
||||
// lets input through again. One of their handlers may block input afresh, and
|
||||
// then the keys after it are withheld in their turn, to be replayed when that
|
||||
// block ends.
|
||||
func (g *Gui) replayBufferedKeys() error {
|
||||
g.replayPending = false
|
||||
|
||||
buffered := g.bufferedKeyEvents
|
||||
g.bufferedKeyEvents = nil
|
||||
for i := range buffered {
|
||||
@@ -1206,7 +1230,7 @@ func (g *Gui) processRemainingEvents() (bool, error) {
|
||||
// handleEvent handles an event, based on its type (key-press, error,
|
||||
// etc.)
|
||||
func (g *Gui) handleEvent(ev *GocuiEvent) error {
|
||||
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
|
||||
if g.withholdingInput() && eventWithheldWhileBlocking(ev) {
|
||||
if ev.Type == eventKey {
|
||||
// Buffer keys so they replay against fresh state on unblock.
|
||||
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
|
||||
@@ -1235,6 +1259,13 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
// withholdingInput reports whether events are being kept from the handlers. They
|
||||
// are while a block is in force, and on until the keys it buffered have been
|
||||
// replayed.
|
||||
func (g *Gui) withholdingInput() bool {
|
||||
return g.blockInputCount > 0 || g.replayPending
|
||||
}
|
||||
|
||||
// eventWithheldWhileBlocking reports whether an event must not reach the
|
||||
// handlers while input is blocked (see BeginBlockingEvents). Key events are
|
||||
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
|
||||
|
||||
+83
-7
@@ -170,6 +170,17 @@ type View struct {
|
||||
// For content that conveys meaning by color of its own.
|
||||
SelectedLineColorWidth int
|
||||
|
||||
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
|
||||
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
|
||||
// set once, when the view is created.
|
||||
InclusionGutterMarker string
|
||||
InclusionGutterMarkerColor Attribute
|
||||
// showInclusionGutter reserves the gutter's columns at the left of every line,
|
||||
// and inclusionGutterMarks, indexed by line of the content, says which lines get
|
||||
// the marker. Set together, via SetInclusionGutter.
|
||||
showInclusionGutter bool
|
||||
inclusionGutterMarks []bool
|
||||
|
||||
// If Frame is true, a border will be drawn around the view.
|
||||
Frame bool
|
||||
|
||||
@@ -860,6 +871,38 @@ func (v *View) Name() string {
|
||||
return v.name
|
||||
}
|
||||
|
||||
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
|
||||
// which marks — indexed by line of the content — say which lines get
|
||||
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
|
||||
// content is drawn shifted past it.
|
||||
//
|
||||
// It is drawn over the content rather than written into it, so the content itself —
|
||||
// and with it what each line of the view means, where a click lands, and how the
|
||||
// lines wrap — is untouched but for the width the gutter takes.
|
||||
func (v *View) SetInclusionGutter(show bool, marks []bool) {
|
||||
v.writeMutex.Lock()
|
||||
changed := v.showInclusionGutter != show
|
||||
v.showInclusionGutter = show
|
||||
v.inclusionGutterMarks = marks
|
||||
v.writeMutex.Unlock()
|
||||
|
||||
if changed {
|
||||
// The gutter takes its columns from the content, so what is left of it wraps
|
||||
// differently, and everything pointing into it has to come along.
|
||||
v.RewrapContent()
|
||||
}
|
||||
}
|
||||
|
||||
// inclusionGutterWidth is how many columns the inclusion gutter takes while it is
|
||||
// shown — the marker plus a column of space before the content — and 0 while it is
|
||||
// not. Only call this with a lock on writeMutex.
|
||||
func (v *View) inclusionGutterWidth() int {
|
||||
if !v.showInclusionGutter {
|
||||
return 0
|
||||
}
|
||||
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
|
||||
}
|
||||
|
||||
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
|
||||
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
|
||||
// position is valid.
|
||||
@@ -1484,9 +1527,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()
|
||||
|
||||
@@ -1764,6 +1807,8 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
|
||||
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
|
||||
|
||||
gutterWidth := v.inclusionGutterWidth()
|
||||
|
||||
for y, vline := range v.viewLines[start:] {
|
||||
if y >= maxY {
|
||||
break
|
||||
@@ -1778,10 +1823,20 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
trailingCell.bgColor = attrs.bg
|
||||
}
|
||||
|
||||
// The inclusion gutter is blank but for the marker on a marked line, and the
|
||||
// content begins after it. The blanks go through setCharacter like everything
|
||||
// else, so that a selection reaching the left edge covers the gutter too.
|
||||
for gx := range gutterWidth {
|
||||
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
|
||||
}
|
||||
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
|
||||
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
|
||||
}
|
||||
|
||||
// x tracks the current x position in the view, and cellIdx tracks the
|
||||
// index of the cell. If we print a double-sized rune, we increment cellIdx
|
||||
// by one but x by two.
|
||||
x := -v.ox
|
||||
x := gutterWidth - v.ox
|
||||
cellIdx := 0
|
||||
|
||||
var c cell
|
||||
@@ -1795,7 +1850,7 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
|
||||
// no more characters to write so we're only going to be printing empty cells
|
||||
// past this point
|
||||
x = 0
|
||||
x = gutterWidth
|
||||
}
|
||||
|
||||
// if we're out of cells to write, we'll just print empty cells.
|
||||
@@ -1830,10 +1885,11 @@ func (v *View) refreshViewLinesIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
maxX := v.InnerWidth()
|
||||
wrap := 0
|
||||
if v.Wrap {
|
||||
wrap = maxX
|
||||
// The inclusion gutter, while it is shown, takes its columns out of the width
|
||||
// the content has to wrap in.
|
||||
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
|
||||
}
|
||||
|
||||
lineIdx := 0
|
||||
@@ -1975,6 +2031,26 @@ func (v *View) BufferLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// MarkedLines returns the lines of the view's content that the inclusion gutter is
|
||||
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
|
||||
// is hidden.
|
||||
func (v *View) MarkedLines() []string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if !v.showInclusionGutter {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
for i, line := range v.buf.lines {
|
||||
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
|
||||
lines = append(lines, line.cells.String())
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
|
||||
// to recover which line of which file it came from: the line's text, which can be
|
||||
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
|
||||
|
||||
@@ -1119,3 +1119,91 @@ func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
// The inclusion gutter reserves columns at the left of every line, draws its marker
|
||||
// on the marked lines only, and moves the content out of the way.
|
||||
func TestInclusionGutter(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// InnerWidth 10; the frame puts view x=0 at screen x=1.
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
v.writeString("aaa\nbbb\nccc\n")
|
||||
|
||||
// The gutter is two columns wide — the marker and a space; mark the middle line.
|
||||
v.SetInclusionGutter(true, []bool{false, true, false})
|
||||
v.draw(true)
|
||||
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, " ", chr, "an unmarked line has no marker")
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "a marked line has one")
|
||||
chr, _, _ = Screen.Get(1, 3)
|
||||
assert.Equal(t, " ", chr, "an unmarked line has no marker")
|
||||
|
||||
// The content begins after the gutter: view x=2, i.e. screen x=3.
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "a", chr)
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "b", chr)
|
||||
chr, _, _ = Screen.Get(3, 3)
|
||||
assert.Equal(t, "c", chr)
|
||||
|
||||
// Hiding the gutter puts the content back at the left edge.
|
||||
v.SetInclusionGutter(false, nil)
|
||||
v.draw(true)
|
||||
chr, _, _ = Screen.Get(1, 1)
|
||||
assert.Equal(t, "a", chr)
|
||||
}
|
||||
|
||||
// A marked line the view wraps is marked on every segment it is drawn as, so that
|
||||
// the mark doesn't look like it belongs to the first part of the line alone. The
|
||||
// gutter takes its columns out of the width the content wraps in.
|
||||
func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
// Ten cells, wrapping at eight once the two-column gutter is shown.
|
||||
v.writeString("0123456789\n")
|
||||
v.SetInclusionGutter(true, []bool{true})
|
||||
v.draw(true)
|
||||
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, "✓", chr)
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "0", chr)
|
||||
chr, _, _ = Screen.Get(10, 1)
|
||||
assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it")
|
||||
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "the line's second segment is marked too")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "8", chr)
|
||||
}
|
||||
|
||||
// Showing the gutter narrows the content, so the content wraps again — and the
|
||||
// positions into it, which count the segments lines are drawn as, have to come
|
||||
// along, as they do for any other change of width.
|
||||
func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
v.writeString("one\ntwo\nthree\nsomethingfartoolong\n")
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
v.FocusPoint(0, 2, false)
|
||||
assert.Equal(t, "three", v.SelectedLine())
|
||||
|
||||
// With eight columns left for the content, the last line wraps into three
|
||||
// segments rather than two.
|
||||
v.SetInclusionGutter(true, []bool{false, false, true, false})
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
assert.Equal(t, "three", v.SelectedLine())
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ type BaseContext struct {
|
||||
windowName string
|
||||
onGetOptionsMap func() map[string]string
|
||||
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
focusedMainViewDiffSource types.FocusedMainViewDiffSource
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
@@ -159,6 +160,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.focusedMainViewDiffSource = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -180,6 +182,15 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddFocusedMainViewDiffSource(source types.FocusedMainViewDiffSource) {
|
||||
if source != nil {
|
||||
if self.focusedMainViewDiffSource != nil {
|
||||
panic("only one controller is allowed to set the focused main view diff source")
|
||||
}
|
||||
self.focusedMainViewDiffSource = source
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetOnDoubleClick() func() error {
|
||||
return self.onDoubleClickFn
|
||||
}
|
||||
@@ -188,6 +199,10 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
|
||||
return self.onClickFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self.focusedMainViewDiffSource
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
|
||||
if fn != nil {
|
||||
if self.onRenderToMainFn != nil {
|
||||
|
||||
@@ -85,10 +85,16 @@ func (self *CommitFilesContext) RefForAdjustingLineNumberInDiff() string {
|
||||
}
|
||||
|
||||
func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
|
||||
if refs := self.GetRefRange(); refs != nil {
|
||||
return refs.From.ParentRefName(), refs.To.RefName()
|
||||
return FromAndToForDiff(self.GetRef(), self.GetRefRange())
|
||||
}
|
||||
|
||||
// FromAndToForDiff gives the two ends to diff for a ref, or for a range of them: a
|
||||
// range runs from the parent of its first ref to its last, a single ref from its own
|
||||
// parent to itself.
|
||||
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
|
||||
if refRange != nil {
|
||||
return refRange.From.ParentRefName(), refRange.To.RefName()
|
||||
}
|
||||
ref := self.GetRef()
|
||||
return ref.ParentRefName(), ref.RefName()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ type MainContext struct {
|
||||
// was worked out from. What there is to select is a property of the content, so an
|
||||
// answer about the content of another render says nothing about this one.
|
||||
selectableContentRenderKey string
|
||||
// contentIsDiff says the render this pane was last given is the diff the panel
|
||||
// beneath offers here, rather than a message or an explanation (see
|
||||
// types.ContentIsDiff). It is known as the render is asked for, well before there
|
||||
// is any content to look at.
|
||||
contentIsDiff bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -65,6 +70,17 @@ func (self *MainContext) SetSelectableContentRenderKey(key string) {
|
||||
self.selectableContentRenderKey = key
|
||||
}
|
||||
|
||||
// ContentIsDiff reports whether this pane is showing the panel's diff (see
|
||||
// contentIsDiff).
|
||||
func (self *MainContext) ContentIsDiff() bool {
|
||||
return self.contentIsDiff
|
||||
}
|
||||
|
||||
// SetContentIsDiff records what kind of render the pane is being given.
|
||||
func (self *MainContext) SetContentIsDiff(value bool) {
|
||||
self.contentIsDiff = value
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
windowName string,
|
||||
|
||||
@@ -8,6 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
|
||||
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
|
||||
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
|
||||
context.AddOnClickFn(controller.GetOnClick())
|
||||
context.AddFocusedMainViewDiffSource(controller.GetFocusedMainViewDiffSource())
|
||||
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
|
||||
context.AddOnFocusFn(controller.GetOnFocus())
|
||||
context.AddOnFocusLostFn(controller.GetOnFocusLost())
|
||||
|
||||
@@ -23,6 +23,10 @@ func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnRenderToMain() func() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// CommitDiffActions implements what a panel showing a commit's diff offers on that diff
|
||||
// in the focused main view. Five panels do: the commit files panel shows the diff of one
|
||||
// file of a commit, and the commits, sub-commits, stash and reflog panels the whole diff
|
||||
// of whatever they have selected. They all offer the same thing and differ only in which
|
||||
// diff they show, so they share this, each saying which diff that is.
|
||||
type CommitDiffActions struct {
|
||||
c *ControllerCommon
|
||||
|
||||
// The panel this belongs to, and what it is showing the diff of — nil when it has
|
||||
// nothing selected, and so no diff.
|
||||
panel types.Context
|
||||
target func() *commitDiffTarget
|
||||
}
|
||||
|
||||
// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it
|
||||
// belongs to a commit lazygit may rewrite.
|
||||
type commitDiffTarget struct {
|
||||
from string
|
||||
to string
|
||||
canRebase bool
|
||||
}
|
||||
|
||||
var _ types.FocusedMainViewActions = &CommitDiffActions{}
|
||||
|
||||
func NewCommitDiffActions(
|
||||
c *ControllerCommon, panel types.Context, target func() *commitDiffTarget,
|
||||
) *CommitDiffActions {
|
||||
return &CommitDiffActions{c: c, panel: panel, target: target}
|
||||
}
|
||||
|
||||
// PlainDiff hands out the diff the asking pane is showing, for the given files — the
|
||||
// commit's diff as in the main view, only without the commit's message and stat above it,
|
||||
// or the diff the custom patch is previewed as, whose lines are the patch's own rather
|
||||
// than the commit's.
|
||||
//
|
||||
// The patch's own diff is handed out whole: it is only ever as big as the patch, and it
|
||||
// names its files under the trees the patch was materialized into rather than under the
|
||||
// paths asked for.
|
||||
func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
|
||||
if self.showsCustomPatch(pane) {
|
||||
return self.customPatchDiff()
|
||||
}
|
||||
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return ""
|
||||
}
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
|
||||
}
|
||||
|
||||
// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the
|
||||
// diff behind what the pane previewing the patch shows, in which the lines shown there
|
||||
// can be found again.
|
||||
func (self *CommitDiffActions) customPatchDiff() string {
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
if treesDir == "" {
|
||||
return ""
|
||||
}
|
||||
// An error means the two trees differ, as they do for any patch with something in
|
||||
// it. We are after the diff itself either way.
|
||||
diff, _ := self.c.Git().Diff.
|
||||
CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain).
|
||||
RunWithOutput()
|
||||
return diff
|
||||
}
|
||||
|
||||
// PrimaryAction takes the selected lines into the custom patch being built from this
|
||||
// diff, or back out of it when the first of them is already in — the same toggling the
|
||||
// commit files panel does to a whole file at a time.
|
||||
//
|
||||
// The commit is not touched, so the diff stays as it is: what changes is the patch
|
||||
// beside it, and which of its lines are marked as being in that patch.
|
||||
func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
|
||||
// In the pane showing the patch, the lines are the patch's own, so there they only
|
||||
// come back out of it.
|
||||
if self.showsCustomPatch(pane) {
|
||||
return self.removePatchLines(pane, firstLineIdx, lastLineIdx)
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
// A patch is built from one diff, so building from another one means giving up the
|
||||
// patch there is — which the user is asked about, as entering the patch builder asks.
|
||||
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
Prompt: self.c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
if !patchBuilder.Active() {
|
||||
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
||||
}
|
||||
|
||||
if err := self.togglePatchLines(lines); err != nil {
|
||||
return err
|
||||
}
|
||||
// Taking the last line back out ends the patch rather than leaving an empty
|
||||
// one, so that the pane previewing it and the marks over the diff go with it.
|
||||
if patchBuilder.IsEmpty() {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
|
||||
// The diff on screen is the one the marks belong to, so they can be brought up
|
||||
// to date at once rather than waiting for the render below.
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
|
||||
// The selection moves on past the lines just toggled, to the next change of
|
||||
// the diff — which is still there, a toggle leaving the diff as it was, so
|
||||
// hold input back until it has moved: a second press meanwhile would toggle
|
||||
// the same lines straight back.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, len(lines),
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
|
||||
// The panel's own render, which is all that is needed: the marks over the diff
|
||||
// and the patch previewed beside it have changed, while the commit has not.
|
||||
self.c.PostRefreshUpdate(self.panel)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// removePatchLines takes the selected lines of the custom patch out of it. The primary
|
||||
// action does this in the pane showing the patch: everything shown there is in the patch
|
||||
// already, so there is nothing else it could mean.
|
||||
//
|
||||
// A line of the patch is named by its position among its file's changes, counted in the
|
||||
// diff the patch is shown as. That position is the same one the line has among the
|
||||
// changes the patch holds for that file. Line numbers would not do: a patch that leaves
|
||||
// an earlier addition out numbers everything after it differently from the commit's diff.
|
||||
func (self *CommitDiffActions) removePatchLines(
|
||||
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
|
||||
) error {
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
previousPaths := self.previousPaths()
|
||||
for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) {
|
||||
filename := self.patchBuilderPath(path)
|
||||
if filename == "" {
|
||||
continue
|
||||
}
|
||||
included := patchBuilder.IncludedChangeLineIndices(filename)
|
||||
indices := []int{}
|
||||
for _, ordinal := range ordinals {
|
||||
if ordinal < len(included) {
|
||||
indices = append(indices, included[ordinal])
|
||||
}
|
||||
}
|
||||
if len(indices) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := patchBuilder.RemoveFileLineRange(filename, previousPaths[filename], indices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Taking the last line out ends the patch rather than leaving an empty one, as it does
|
||||
// in the diff beside this pane.
|
||||
if patchBuilder.IsEmpty() {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
|
||||
// The lines are gone from the patch, so the selection carries on from where they were,
|
||||
// as unstaging leaves it. Input is held until it has moved, so that a second press acts
|
||||
// on the patch as it now is.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstLineIdx, 0,
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
|
||||
self.c.PostRefreshUpdate(self.panel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardSelection takes the selected lines out of the commit they are part of, by
|
||||
// building a patch of exactly those lines and removing that patch from the commit. It is
|
||||
// a rebase, so a later commit that touches the same lines can conflict with it.
|
||||
//
|
||||
// The patch it needs is its own, so a patch being built is given up first — which the
|
||||
// prompt says, there being no way to get it back.
|
||||
func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
commitIndex := self.indexOfTargetCommit(target)
|
||||
if commitIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
prompt := lo.Ternary(patchBuilder.IsEmpty(),
|
||||
self.c.Tr.DiscardLinesFromCommitPrompt,
|
||||
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardLinesFromCommitTitle,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
patchBuilder.Reset()
|
||||
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
||||
if err := self.togglePatchLines(lines); err != nil {
|
||||
return err
|
||||
}
|
||||
if patchBuilder.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The rebase runs on a worker, which may not read the model, so the commits
|
||||
// it rewrites are taken here.
|
||||
commits := self.c.Model().Commits
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
|
||||
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the
|
||||
// commit: doing so rewrites it, which is only ours to do for a commit of the branch we
|
||||
// are on, and not while a rebase is already under way. In the pane previewing the custom
|
||||
// patch there is nothing to discard from — the lines there are the patch's, and space
|
||||
// takes them back out of it.
|
||||
func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason {
|
||||
if self.showsCustomPatch(pane) {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true}
|
||||
}
|
||||
target := self.target()
|
||||
if target == nil || !target.canRebase {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
|
||||
}
|
||||
if self.c.Git().Status.WorkingTreeState().Any() {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
|
||||
}
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return &types.DisabledReason{
|
||||
Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView),
|
||||
ShowErrorInPanel: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchInclusion says which lines of the commit's diff are in the custom patch being
|
||||
// built from it. nil when there is no such patch: none is being built at all, or the one
|
||||
// being built is of another diff, whose lines are not these however alike they look.
|
||||
func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
target := self.target()
|
||||
if !patchBuilder.Active() || target == nil {
|
||||
return nil
|
||||
}
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
if patchBuilder.NewPatchRequired(from, target.to, reverse) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Which lines of a file are in the patch is asked of the patch builder per file, and
|
||||
// a diff can span many, so each is asked about when a line of it first comes up.
|
||||
includedByPath := map[string]*set.Set[patch.LineIdentity]{}
|
||||
return func(info types.DiffLineInfo) bool {
|
||||
path := self.patchBuilderPath(info.Path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
included, asked := includedByPath[path]
|
||||
if !asked {
|
||||
included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path))
|
||||
includedByPath[path] = included
|
||||
}
|
||||
return included.Includes(info.PatchLineIdentity())
|
||||
}
|
||||
}
|
||||
|
||||
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
|
||||
// out of it. The first line of the selection decides which of the two happens, once for
|
||||
// the whole selection: pointing at a line that is already in the patch takes the whole
|
||||
// selection out of it, as toggling a selection of files in the commit files panel does.
|
||||
func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error {
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
|
||||
// The files the selection covers, in the order the diff shows them, and per file the
|
||||
// lines of it that are selected: a patch is built a file at a time, while a selection
|
||||
// can span several of them.
|
||||
paths := []string{}
|
||||
linesByPath := map[string][]patch.LineIdentity{}
|
||||
for _, line := range lines {
|
||||
path := self.patchBuilderPath(line.Path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := linesByPath[path]; !seen {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity())
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousPaths := self.previousPaths()
|
||||
indicesByPath := map[string][]int{}
|
||||
for _, path := range paths {
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines(path, previousPaths[path], linesByPath[path])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indicesByPath[path] = indices
|
||||
}
|
||||
|
||||
included, err := patchBuilder.GetFileIncLineIndices(paths[0], previousPaths[paths[0]])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toggle := patchBuilder.AddFileLineRange
|
||||
if first := indicesByPath[paths[0]]; len(first) > 0 && lo.Contains(included, first[0]) {
|
||||
toggle = patchBuilder.RemoveFileLineRange
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if len(indicesByPath[path]) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := toggle(path, previousPaths[path], indicesByPath[path]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// previousPaths says which files of the diff were renamed, and what they were called
|
||||
// before. A renamed file's diff only comes out as a rename when git is asked about both
|
||||
// of its paths, and its lines are numbered in the file under its old name, so the patch
|
||||
// builder has to be told the old path along with them.
|
||||
func (self *CommitDiffActions) previousPaths() map[string]string {
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousPaths := map[string]string{}
|
||||
for _, file := range files {
|
||||
if file.PreviousPath != "" {
|
||||
previousPaths[file.Path] = file.PreviousPath
|
||||
}
|
||||
}
|
||||
return previousPaths
|
||||
}
|
||||
|
||||
// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends
|
||||
// of the diff shown, except in diffing mode, where what is shown is a diff against
|
||||
// another ref, possibly the other way around.
|
||||
func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) {
|
||||
return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from)
|
||||
}
|
||||
|
||||
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
|
||||
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
|
||||
func (self *CommitDiffActions) patchBuilderPath(path string) string {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
|
||||
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
|
||||
// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it
|
||||
// isn't one of them, in which case there is nothing we can rewrite.
|
||||
func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int {
|
||||
return lo.IndexOf(
|
||||
lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }),
|
||||
target.to)
|
||||
}
|
||||
|
||||
// showsCustomPatch reports whether the given main pane is the one previewing the custom
|
||||
// patch being built, rather than the commit's diff — which for a commit's diff is always
|
||||
// the lower one.
|
||||
func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool {
|
||||
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
|
||||
}
|
||||
@@ -23,6 +23,9 @@ type CommitFilesController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*filetree.CommitFileNode]
|
||||
c *ControllerCommon
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
var _ types.IController = &CommitFilesController{}
|
||||
@@ -30,7 +33,7 @@ var _ types.IController = &CommitFilesController{}
|
||||
func NewCommitFilesController(
|
||||
c *ControllerCommon,
|
||||
) *CommitFilesController {
|
||||
return &CommitFilesController{
|
||||
controller := &CommitFilesController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
ListControllerTrait: NewListControllerTrait(
|
||||
@@ -40,6 +43,18 @@ func NewCommitFilesController(
|
||||
c.Contexts().CommitFiles.GetSelectedItems,
|
||||
),
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, c.Contexts().CommitFiles, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the commit whose files this panel is showing. Its main view shows the
|
||||
// diff of that commit.
|
||||
func (self *CommitFilesController) diffTarget() *commitDiffTarget {
|
||||
if self.context().GetRef() == nil && self.context().GetRefRange() == nil {
|
||||
return nil
|
||||
}
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
return &commitDiffTarget{from: from, to: to, canRebase: self.context().GetCanRebase()}
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -175,9 +190,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
|
||||
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
|
||||
task := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, mode)
|
||||
task := types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
@@ -191,11 +207,15 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain)
|
||||
diff, err := cmdObj.RunWithOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -21,6 +21,9 @@ type FilesController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*filetree.FileNode]
|
||||
c *ControllerCommon
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *WorkingTreeDiffActions
|
||||
}
|
||||
|
||||
var _ types.IController = &FilesController{}
|
||||
@@ -36,6 +39,7 @@ func NewFilesController(
|
||||
c.Contexts().Files.GetSelected,
|
||||
c.Contexts().Files.GetSelectedItems,
|
||||
),
|
||||
diffActions: NewWorkingTreeDiffActions(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +352,7 @@ func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) {
|
||||
message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr))
|
||||
|
||||
if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" {
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()})
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}, git_commands.DiffModeRendered)
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
@@ -366,42 +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
|
||||
|
||||
// While the main view is focused to act on this diff, it may have to be git's own
|
||||
// rather than the diff renderer's; both panes have to agree about that.
|
||||
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
|
||||
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.UnstagedChanges
|
||||
if showUnstaged {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths)
|
||||
refreshOpts.Main = &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: self.c.Tr.UnstagedChanges,
|
||||
NothingToActOn: !node.GetHasUnstagedChanges(),
|
||||
}
|
||||
}
|
||||
|
||||
if showStaged {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths)
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()),
|
||||
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: self.c.Tr.StagedChanges,
|
||||
NothingToActOn: !node.GetHasStagedChanges(),
|
||||
}
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
}
|
||||
|
||||
func (self *FilesController) 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})
|
||||
|
||||
@@ -99,21 +99,27 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingSt
|
||||
self.modeHelper.SetSuppressWorkingTreeStateMode(true)
|
||||
}
|
||||
self.c.OnWorker(func(task gocui.Task) error {
|
||||
// End the block and restore the mode indicator once the operation and its
|
||||
// refresh have applied their UI updates: OnUIThread queues this after the
|
||||
// refresh's model bounces and Then (which RefreshFromWorker has already
|
||||
// enqueued by the time f returns), so the replayed keys act on the
|
||||
// refreshed state and any resulting working tree state shows correctly.
|
||||
defer self.c.OnUIThread(func() error {
|
||||
if opts.HideWorkingTreeState {
|
||||
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
|
||||
}
|
||||
return self.c.GocuiGui().EndBlockingEvents()
|
||||
})
|
||||
defer self.endBlockingInput(opts.HideWorkingTreeState)
|
||||
return self.WithWaitingStatusImpl(opts.Message, f, task)
|
||||
})
|
||||
}
|
||||
|
||||
// endBlockingInput lets input through again once the operation and its refresh
|
||||
// have applied their UI updates, and restores the mode indicator with it.
|
||||
// OnUIThread queues this after the refresh's model bounces and Then (which
|
||||
// RefreshFromWorker has already enqueued by the time the operation returns), so
|
||||
// the replayed keys act on the refreshed state and any resulting working tree
|
||||
// state shows correctly.
|
||||
func (self *AppStatusHelper) endBlockingInput(hideWorkingTreeState bool) {
|
||||
self.c.OnUIThread(func() error {
|
||||
if hideWorkingTreeState {
|
||||
self.modeHelper.SetSuppressWorkingTreeStateMode(false)
|
||||
}
|
||||
self.c.GocuiGui().EndBlockingEvents()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *AppStatusHelper) HasStatus() bool {
|
||||
return self.statusMgr().HasStatus()
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ func (self *DiffHelper) DiffArgs() []string {
|
||||
// either there's no range, or it can't be diffed for some reason), then we want
|
||||
// to fall back to rendering the diff for the single commit.
|
||||
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask {
|
||||
mode := self.diffLineHelper.MainViewDiffMode()
|
||||
|
||||
if refRange != nil {
|
||||
from, to := refRange.From, refRange.To
|
||||
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
|
||||
@@ -74,13 +76,26 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm
|
||||
args = append(args, filterPath)
|
||||
}
|
||||
}
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args, mode)
|
||||
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
|
||||
return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode)
|
||||
}
|
||||
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
|
||||
return types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode)
|
||||
return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
|
||||
}
|
||||
|
||||
// PlainDiffBetweenRefs returns the diff of the given files between two refs as git
|
||||
// writes it, without colour or a diff renderer's involvement — what a panel showing
|
||||
// a commit's diff hands out as the diff behind its rendering (see
|
||||
// types.FocusedMainViewDiffSource). It honours diffing mode, so that the diff is of
|
||||
// the same two ends the main view is showing.
|
||||
func (self *DiffHelper) PlainDiffBetweenRefs(from string, to string, paths []string) string {
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
// An error means there is no diff to be had, which for our purposes is the same
|
||||
// as an empty one.
|
||||
diff, _ := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModePlain).RunWithOutput()
|
||||
return diff
|
||||
}
|
||||
|
||||
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
|
||||
@@ -120,13 +135,13 @@ func (self *DiffHelper) RenderToMainAgain() {
|
||||
|
||||
func (self *DiffHelper) RenderDiff() {
|
||||
args := self.DiffArgs()
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered)
|
||||
prefix := style.FgMagenta.Sprintf(
|
||||
"%s %s\n\n",
|
||||
self.c.Tr.ShowingGitDiff,
|
||||
"git diff "+strings.Join(args, " "),
|
||||
)
|
||||
task := types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
task := types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, git_commands.DiffModeRendered)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
|
||||
@@ -9,6 +9,11 @@ import (
|
||||
|
||||
type DiffLineHelper struct {
|
||||
c *HelperCommon
|
||||
|
||||
// What the probe said about the diff renderer that rendererSignature names, or nil
|
||||
// before it has been asked about any (see diffRendererEmitsMetadata).
|
||||
rendererEmitsMetadata *bool
|
||||
rendererSignature string
|
||||
}
|
||||
|
||||
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
|
||||
@@ -61,7 +66,7 @@ func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
|
||||
if renderingStatesDiffLines(contents) {
|
||||
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
|
||||
return identities, true
|
||||
return self.inRepoTerms(view, identities), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -71,7 +76,7 @@ func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
|
||||
return self.inRepoTerms(view, []types.DiffLineInfo{self.diffLineInfo(parsed)}), true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
|
||||
@@ -86,6 +91,18 @@ func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []t
|
||||
return self.diffLineInfos(parseDiffLineRecords(metadata))
|
||||
}
|
||||
|
||||
// diffLineInfoFromRecords recovers a row's own identity from the records the diff
|
||||
// renderer stated for it. That is the line the row leads with, of the ones
|
||||
// diffLineIdentitiesFromRecords finds on it. ok is false when the row carries no
|
||||
// record we understand.
|
||||
func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) {
|
||||
identities := self.diffLineIdentitiesFromRecords(metadata)
|
||||
if len(identities) == 0 {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
|
||||
// be recovered at all — the element of the table resolveDiffLines produces.
|
||||
type resolvedDiffLine struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
@@ -11,6 +14,113 @@ import (
|
||||
// cursor and a click speak. They are all built on the identities recovered in
|
||||
// diff_line_helper.go, which is where the answering stops and the recovering starts.
|
||||
|
||||
// DiffLinesInViewRange returns the identity of every diff line shown by the rows in
|
||||
// the inclusive view-line range [first, last] of view's rendered diff, in display
|
||||
// order. Rows whose identity can't be recovered are left out, as are the wrapped
|
||||
// segments of a row already counted.
|
||||
//
|
||||
// A row can show more than one diff line — a side-by-side rendering puts a deletion
|
||||
// beside the addition replacing it — and all of them are reported: what the user
|
||||
// pointed at is the row, so everything on it is selected.
|
||||
func (self *DiffLineHelper) DiffLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
|
||||
identities := self.resolveDiffLineIdentities(view.DiffLineContents())
|
||||
|
||||
infos := []types.DiffLineInfo{}
|
||||
previousBufferLine := -1
|
||||
for viewLine := first; viewLine <= last; viewLine++ {
|
||||
bufferLine, ok := view.BufferLineForViewLine(viewLine)
|
||||
if !ok || bufferLine == previousBufferLine || bufferLine >= len(identities) {
|
||||
continue
|
||||
}
|
||||
previousBufferLine = bufferLine
|
||||
infos = append(infos, identities[bufferLine]...)
|
||||
}
|
||||
return self.inRepoTerms(view, infos)
|
||||
}
|
||||
|
||||
// ChangeLineOrdinals says, for each of the given change lines, which of its file's
|
||||
// changes it is in the given diff — its place among them, counted from the top of the
|
||||
// file — keyed by file. Lines the diff doesn't have are left out.
|
||||
//
|
||||
// It is how a line is named in something built out of a diff rather than being that diff:
|
||||
// the custom patch holds the lines it was given in the order the file has them, so a
|
||||
// place among a file's changes is a line of the patch.
|
||||
func (self *DiffLineHelper) ChangeLineOrdinals(
|
||||
diff string, infos []types.DiffLineInfo,
|
||||
) map[string][]int {
|
||||
ordinals := map[patchLine]int{}
|
||||
counts := map[string]int{}
|
||||
for _, parsed := range parseAllDiffLinesFromBuffer(strings.Split(diff, "\n")) {
|
||||
if !parsed.ok {
|
||||
continue
|
||||
}
|
||||
info := self.diffLineInfo(parsed.parsed)
|
||||
if !info.IsChange() {
|
||||
continue
|
||||
}
|
||||
ordinals[patchLineOf(info)] = counts[info.Path]
|
||||
counts[info.Path]++
|
||||
}
|
||||
|
||||
ordinalsByPath := map[string][]int{}
|
||||
for _, info := range infos {
|
||||
if ordinal, ok := ordinals[patchLineOf(info)]; ok {
|
||||
ordinalsByPath[info.Path] = append(ordinalsByPath[info.Path], ordinal)
|
||||
}
|
||||
}
|
||||
return ordinalsByPath
|
||||
}
|
||||
|
||||
// inRepoTerms brings the paths of lines recovered from a view into the repo's terms.
|
||||
//
|
||||
// They are in them already for a diff of the repo's own files. The pane previewing the
|
||||
// custom patch, though, shows a diff of the two trees the patch was materialized into: a
|
||||
// diff renderer states the path it was handed there, which is under the tree's own name,
|
||||
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
|
||||
// prefixes and so needs nothing.
|
||||
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
|
||||
if !self.ShowsCustomPatch(view) {
|
||||
return infos
|
||||
}
|
||||
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
|
||||
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
|
||||
// into to the file of the repo it stands for: the path is the tree's name followed by the
|
||||
// file's own, stated either against the directory holding the trees or against the repo,
|
||||
// depending on how the renderer that stated it was given it.
|
||||
func repoPathOfTreePath(path string, treesDir string, worktreePath string) string {
|
||||
root := worktreePath
|
||||
if treesDir != "" && strings.HasPrefix(path, treesDir+string(filepath.Separator)) {
|
||||
root = treesDir
|
||||
}
|
||||
relativePath, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
|
||||
segments := strings.Split(filepath.ToSlash(relativePath), "/")
|
||||
if len(segments) > 1 && (segments[0] == "a" || segments[0] == "b") {
|
||||
relativePath = filepath.Join(segments[1:]...)
|
||||
}
|
||||
return filepath.Join(worktreePath, relativePath)
|
||||
}
|
||||
|
||||
// ChangeLinesInViewRange returns the change lines — the additions and deletions —
|
||||
// among the diff lines shown by the rows in the inclusive view-line range. Those are
|
||||
// the lines a patch is built from: a patch carries whatever context it needs around
|
||||
// them by itself, so a selection contributes only its changes.
|
||||
func (self *DiffLineHelper) ChangeLinesInViewRange(view *gocui.View, first int, last int) []types.DiffLineInfo {
|
||||
return lo.Filter(self.DiffLinesInViewRange(view, first, last),
|
||||
func(info types.DiffLineInfo, _ int) bool { return info.IsChange() })
|
||||
}
|
||||
|
||||
// changeLines resolves view's rendered diff to one flag per buffer line: whether
|
||||
// that row is a change line (an addition or a deletion), as opposed to context, a
|
||||
// header, or a row whose identity couldn't be recovered. Those are the rows a
|
||||
@@ -191,17 +301,6 @@ func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine i
|
||||
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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
)
|
||||
|
||||
// Falling back to git's own diff when the configured one can't be acted on.
|
||||
//
|
||||
// A diff renderer is free to lay a diff out however it likes, and once it has, we can
|
||||
// only tell which line of which file a row shows if the renderer says so. A renderer
|
||||
// that doesn't produces a diff that can be read but not staged, edited or copied from,
|
||||
// so when the user focuses the main view to act on it, we show git's own diff instead.
|
||||
// Browsing keeps the renderer's version; only acting on it needs one we can follow.
|
||||
|
||||
// MainViewDiffMode says how a side panel should produce the diff it renders into the
|
||||
// main view: as the user configured it, or as git's own — while the main view holds
|
||||
// focus and what the renderer would produce couldn't be acted on.
|
||||
//
|
||||
// Every panel that renders a diff into the main view asks, so that a re-render while
|
||||
// focused — after staging a hunk, say — stays with git's own diff rather than flipping
|
||||
// back to the renderer's.
|
||||
func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
|
||||
if self.mainViewIsFocused() && self.diffNeedsMetadata() && !self.diffRendererEmitsMetadata() {
|
||||
return git_commands.DiffModeRaw
|
||||
}
|
||||
return git_commands.DiffModeRendered
|
||||
}
|
||||
|
||||
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
|
||||
// diff again — which, the main view now holding focus, is git's own diff rather than
|
||||
// the renderer's — and calls place once that is on screen.
|
||||
//
|
||||
// The whole diff is read before it is shown, rather than the first screenful: place
|
||||
// looks at what is there to decide where to put the selection, and a change line
|
||||
// further down would otherwise be missed.
|
||||
//
|
||||
// It is the same diff of the same files, so the view keeps the scroll position it has
|
||||
// rather than starting from the top: git lays the changes out its own way and the line
|
||||
// the user was on is somewhere else now, but the offset still puts them among the same
|
||||
// part of the file — and the selection is then established from what that leaves on
|
||||
// screen.
|
||||
func (self *DiffLineHelper) RenderFocusedMainViewAgain(view *gocui.View, sidePanel types.Context, place func()) {
|
||||
manager := self.c.GetOrCreateViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.SetKeepScrollPositionForNextTask()
|
||||
manager.SetRestoreForNextTask(&tasks.RenderRestore{
|
||||
FirstPaintReady: func() bool { return false },
|
||||
Apply: func(swapIn func()) {
|
||||
swapIn()
|
||||
place()
|
||||
},
|
||||
})
|
||||
|
||||
sidePanel.HandleRenderToMain()
|
||||
}
|
||||
|
||||
func (self *DiffLineHelper) mainViewIsFocused() bool {
|
||||
current := self.c.Context().CurrentStatic().GetKey()
|
||||
return current == self.c.Contexts().Normal.GetKey() ||
|
||||
current == self.c.Contexts().NormalSecondary.GetKey()
|
||||
}
|
||||
|
||||
// diffNeedsMetadata reports whether the diff we would show is one whose rows can only
|
||||
// be placed in the file by the records the renderer states. Any custom renderer may
|
||||
// restructure the diff; so may git itself, once the renderer's arguments ask for a word
|
||||
// diff, whose markup is inline. Plain git output describes itself, and needs no records.
|
||||
func (self *DiffLineHelper) diffNeedsMetadata() bool {
|
||||
manager := self.c.State().GetDiffRendererConfigManager()
|
||||
if manager.GetDiffRendererType() != config.DiffRendererType_RawGit {
|
||||
return true
|
||||
}
|
||||
return len(manager.GetRawGitArgs()) > 0
|
||||
}
|
||||
|
||||
// diffRendererEmitsMetadata is the probed verdict about the current diff renderer, asked
|
||||
// once and remembered until the renderer changes — the user cycling to another one, or a
|
||||
// changed config being reloaded.
|
||||
func (self *DiffLineHelper) diffRendererEmitsMetadata() bool {
|
||||
signature := self.diffRendererSignature()
|
||||
if self.rendererEmitsMetadata == nil || signature != self.rendererSignature {
|
||||
verdict := self.c.Git().Diff.ProbeDiffRendererEmitsMetadata()
|
||||
self.rendererEmitsMetadata = &verdict
|
||||
self.rendererSignature = signature
|
||||
}
|
||||
return *self.rendererEmitsMetadata
|
||||
}
|
||||
|
||||
// diffRendererSignature identifies the current diff renderer, so that the remembered
|
||||
// verdict is dropped when it stops describing the renderer we have. The values a command
|
||||
// is resolved with are no part of its identity, so fixed ones are used.
|
||||
func (self *DiffLineHelper) diffRendererSignature() string {
|
||||
manager := self.c.State().GetDiffRendererConfigManager()
|
||||
index, _ := manager.CurrentDiffRendererIndex()
|
||||
values := config.DiffRendererValues{}
|
||||
externalDiff, _ := manager.GetExternalDiffCommand(values)
|
||||
stdinFilter, _ := manager.GetStdinFilterCommand(values)
|
||||
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
|
||||
index,
|
||||
externalDiff,
|
||||
stdinFilter,
|
||||
strings.Join(manager.GetRawGitArgs(), "\x00"))
|
||||
}
|
||||
@@ -230,20 +230,148 @@ func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLi
|
||||
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 {
|
||||
if 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()
|
||||
// The search of the loading content runs on the task's own goroutine, where the
|
||||
// repo we are in may not be read — a repo switch replaces it — so take it here, on
|
||||
// the UI thread, for the search to work from.
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
|
||||
// Which candidate the search settled on, for place to put back where it was.
|
||||
found := diffLineAnchor{}
|
||||
|
||||
self.installDiffLineRestore(view,
|
||||
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
|
||||
for i, row := range rows {
|
||||
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
|
||||
found = candidates[0]
|
||||
return offset + i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
},
|
||||
func(contents []gocui.DiffLineContent) (int, bool) {
|
||||
anchor, bufferLine := self.nearestSurvivingCandidate(contents, candidates)
|
||||
if bufferLine == -1 {
|
||||
return 0, false
|
||||
}
|
||||
found = anchor
|
||||
return bufferLine, true
|
||||
},
|
||||
func(viewLine int) { place(found, viewLine) },
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// ChangeLineOrdinal returns how many change lines of view's rendered diff come before
|
||||
// the one at the given view line — that line's place in the sequence of changes. ok is
|
||||
// false when the view line belongs to no row of the content.
|
||||
//
|
||||
// It is how a place in a diff is remembered across acting on it: an action consumes
|
||||
// the lines it acted on, so the identity of the line the user was on is gone, but the
|
||||
// place it left behind is the same one that identity used to have.
|
||||
func (self *DiffLineHelper) ChangeLineOrdinal(view *gocui.View, viewLine int) (int, bool) {
|
||||
bufferLine, ok := view.BufferLineForViewLine(viewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
ordinal := 0
|
||||
for i, row := range self.resolveDiffLines(view.DiffLineContents()) {
|
||||
if i >= bufferLine {
|
||||
break
|
||||
}
|
||||
if row.ok && row.info.IsChange() {
|
||||
ordinal++
|
||||
}
|
||||
}
|
||||
return ordinal, true
|
||||
}
|
||||
|
||||
// RevealChangeLineAtOrdinal arranges for view's next re-render to be shown with the
|
||||
// change line at the given ordinal placed by place — the diff having changed under the
|
||||
// user, this is where what they were doing carries on. When the new diff has fewer
|
||||
// changes than that, because the ones acted on were its last, it lands on the last
|
||||
// change left.
|
||||
//
|
||||
// done is called once the selection is where it belongs, or once it turns out that no
|
||||
// render is coming to put it there, for a caller that must not let the user act again
|
||||
// in between.
|
||||
func (self *DiffLineHelper) RevealChangeLineAtOrdinal(
|
||||
view *gocui.View, ordinal int, place func(viewLine int), done func(),
|
||||
) {
|
||||
// How many change lines the incremental search has passed, so that it can carry on
|
||||
// counting where it left off.
|
||||
seen := 0
|
||||
|
||||
self.installDiffLineRestore(view,
|
||||
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
|
||||
for i, row := range rows {
|
||||
if info, ok := self.diffLineInfoFromRecords(row.Metadata); ok && info.IsChange() {
|
||||
if seen == ordinal {
|
||||
return offset + i, true
|
||||
}
|
||||
seen++
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
},
|
||||
func(contents []gocui.DiffLineContent) (int, bool) {
|
||||
last, count := -1, 0
|
||||
for i, row := range self.resolveDiffLines(contents) {
|
||||
if !row.ok || !row.info.IsChange() {
|
||||
continue
|
||||
}
|
||||
if count == ordinal {
|
||||
return i, true
|
||||
}
|
||||
count++
|
||||
last = i
|
||||
}
|
||||
return last, last != -1
|
||||
},
|
||||
place,
|
||||
done,
|
||||
)
|
||||
}
|
||||
|
||||
// installDiffLineRestore is what the restores are built on: it arranges for view's
|
||||
// next re-render to be revealed with the row a search finds in it placed by place,
|
||||
// instead of from the top.
|
||||
//
|
||||
// The search comes in two halves, because the content arrives a line at a time.
|
||||
// findEarly is given the rows that have loaded since it last looked, so that the
|
||||
// re-render can be revealed as soon as the row is there rather than waiting for the
|
||||
// rest of a long diff; it can only go by what the renderer states about a row, a
|
||||
// partly-loaded diff being unparseable. findComplete is given the whole rendering at
|
||||
// the swap, for a target the incremental search couldn't settle on. Either returns the
|
||||
// buffer line it found, and place is not called at all when neither does.
|
||||
func (self *DiffLineHelper) installDiffLineRestore(
|
||||
view *gocui.View,
|
||||
findEarly func(rows []gocui.DiffLineContent, offset int) (int, bool),
|
||||
findComplete func(contents []gocui.DiffLineContent) (int, bool),
|
||||
place func(viewLine int),
|
||||
done func(),
|
||||
) {
|
||||
// Get-or-create, because the pane may not have rendered anything yet: a file whose
|
||||
// diff has only just become split has a second pane whose first render is the one
|
||||
// this restore is for.
|
||||
manager := self.c.GetOrCreateViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
if done != nil {
|
||||
done()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The readiness check below runs on the task's own goroutine, which may not read
|
||||
// the view's dimensions, so take them here, on the UI thread.
|
||||
viewHeight := view.InnerHeight()
|
||||
|
||||
// What the search of the loading content has found, and how far it has looked, so
|
||||
// that each line is looked at once.
|
||||
found := diffLineAnchor{}
|
||||
foundLine := -1
|
||||
scanned := 0
|
||||
|
||||
@@ -251,11 +379,8 @@ func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
|
||||
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
|
||||
}
|
||||
if bufferLine, ok := findEarly(rows, scanned); ok {
|
||||
foundLine = bufferLine
|
||||
}
|
||||
scanned += len(rows)
|
||||
if foundLine == -1 {
|
||||
@@ -266,24 +391,24 @@ func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
|
||||
// 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
|
||||
Apply: func(swapIn func()) {
|
||||
bufferLine := foundLine
|
||||
if bufferLine == -1 {
|
||||
anchor, bufferLine = self.nearestSurvivingCandidate(view.OffscreenDiffLineContents(), candidates)
|
||||
if line, ok := findComplete(view.OffscreenDiffLineContents()); ok {
|
||||
bufferLine = line
|
||||
}
|
||||
}
|
||||
|
||||
swapIn()
|
||||
|
||||
if bufferLine == -1 {
|
||||
return false
|
||||
return
|
||||
}
|
||||
viewLine, ok := view.ViewLineForBufferLine(bufferLine)
|
||||
if !ok {
|
||||
return false
|
||||
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
|
||||
place(viewLine)
|
||||
}
|
||||
place(anchor, viewLine)
|
||||
return true
|
||||
},
|
||||
Done: done,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -424,3 +549,59 @@ func patchLineOf(info types.DiffLineInfo) patchLine {
|
||||
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
|
||||
}
|
||||
}
|
||||
|
||||
// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the
|
||||
// place of the one just acted on, once the changed diff has re-rendered. Call it with
|
||||
// the pane acted in, the pane the work carries on in, and the first line of the
|
||||
// selection, before triggering the re-render.
|
||||
//
|
||||
// The line acted on is gone from the diff, so what is remembered is its place among the
|
||||
// diff's changes: the next change moves up into it, which is where you want to be to
|
||||
// carry on. A range collapses to a single line at its start, and hunk mode selects the
|
||||
// whole block it lands in, so that pressing the key again acts on the next hunk. The
|
||||
// target pane inherits that select mode, this being the same piece of work continuing
|
||||
// in another pane — and shows no selection until the restore places one, so that what
|
||||
// it was left showing the last time it was used doesn't appear for a frame.
|
||||
//
|
||||
// advanceBy moves on by that many changes past the place remembered, for an action that
|
||||
// leaves the diff as it was: lines taken into a custom patch are still in the commit's
|
||||
// diff, so the place remembered is still the line acted on, and carrying on means going
|
||||
// past the lines just dealt with rather than staying on them.
|
||||
//
|
||||
// done, which may be nil, is called once the selection is where it belongs, or once it
|
||||
// turns out that no render is coming to put it there — for a caller that must not let
|
||||
// the user act again in between.
|
||||
func (self *DiffLineHelper) RevealSelectionAfterAction(
|
||||
source types.DiffPaneContext, target types.DiffPaneContext, firstLineIdx int, advanceBy int, done func(),
|
||||
) {
|
||||
ordinal, ok := self.ChangeLineOrdinal(source.GetView(), firstLineIdx)
|
||||
if !ok {
|
||||
if done != nil {
|
||||
done()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sel := source.DiffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
}
|
||||
*target.DiffSelectState() = *sel
|
||||
selectHunk := sel.Mode == types.DiffSelectModeHunk
|
||||
|
||||
targetView := target.GetView()
|
||||
if target != source {
|
||||
target.SetHasSelectableContent(false)
|
||||
self.c.Context().UpdateSelectionHighlights()
|
||||
}
|
||||
|
||||
self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) {
|
||||
if selectHunk {
|
||||
self.SelectChangeBlock(target, viewLine, true)
|
||||
return
|
||||
}
|
||||
targetView.CancelRangeSelect()
|
||||
self.ShowSelectionAtLine(targetView, viewLine, true)
|
||||
}, done)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Putting a selection in the focused main view: where it starts out, and how it is
|
||||
// widened to a whole change block. Both are answered from what the view is showing,
|
||||
// as recovered by the queries next door.
|
||||
|
||||
// EstablishSelection turns on the focused main view's selection once the view has
|
||||
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
|
||||
// keyboard focus, which points at no particular line and so starts at the first
|
||||
// change line on screen.
|
||||
//
|
||||
// Focusing never moves the view: you focus the diff you are reading in order to point
|
||||
// at something in it, so the selection goes where you are looking rather than the
|
||||
// view going where the selection would like to be. With no change line on screen at
|
||||
// all — a long stretch of context — it lands on the middle visible line, the likeliest
|
||||
// one to be the one being read.
|
||||
//
|
||||
// With hunk mode configured as the default the selection widens to the whole change
|
||||
// block: keyboard focus lands on the first block on screen, and a click on a change
|
||||
// line selects that line's block, ready to act on. A click on context still selects
|
||||
// just that line — the click points at it precisely, so it stays editable.
|
||||
func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, clickedViewLine int) {
|
||||
mainContext.ResetDiffSelectMode()
|
||||
view := mainContext.GetView()
|
||||
|
||||
// The pane may hold nothing to act on: a diff of a binary file or an empty commit,
|
||||
// or content that is no diff of the panel's at all. Rendering it worked that out,
|
||||
// so the pane is already showing no selection and there is nowhere to put one.
|
||||
if !mainContext.HasSelectableContent() {
|
||||
return
|
||||
}
|
||||
|
||||
if clickedViewLine >= 0 {
|
||||
// Remember where the click landed so that a drag that follows anchors its range
|
||||
// there, even when this click selects a whole hunk.
|
||||
mainContext.SetDragAnchorViewLine(clickedViewLine)
|
||||
if self.hunkModeApplies(view, clickedViewLine) && self.IsChangeLine(view, clickedViewLine) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
self.SelectChangeBlock(mainContext, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
self.ShowSelectionAtLine(view, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := self.changeToSelectOnScreen(view)
|
||||
if !ok {
|
||||
self.ShowSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
|
||||
return
|
||||
}
|
||||
if self.hunkModeApplies(view, target) {
|
||||
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
|
||||
self.SelectChangeBlock(mainContext, target, false)
|
||||
return
|
||||
}
|
||||
self.ShowSelectionAtLine(view, target, false)
|
||||
}
|
||||
|
||||
// changeToSelectOnScreen returns the change line keyboard focus establishes the
|
||||
// selection on. In hunk mode that is the first block that begins on screen, so that
|
||||
// the block being offered up is one the user can see the extent of, falling back to a
|
||||
// block that reaches into the view from above — a change longer than the screen, where
|
||||
// there is nothing else to offer. Line by line it is simply the first change line on
|
||||
// screen. ok is false when the viewport shows no change at all.
|
||||
func (self *DiffLineHelper) changeToSelectOnScreen(view *gocui.View) (int, bool) {
|
||||
if self.c.UserConfig().Gui.UseHunkModeInStagingView {
|
||||
return self.FirstChangeBlockInView(view)
|
||||
}
|
||||
return self.FirstChangeLineInView(view)
|
||||
}
|
||||
|
||||
// hunkModeApplies reports whether an established selection should start out as the
|
||||
// whole change block around the given change line. That's what the config asks for,
|
||||
// except over a file shown as one solid block of changes, where it would select the
|
||||
// whole file — see IsSingleHunkForWholeFile.
|
||||
func (self *DiffLineHelper) hunkModeApplies(view *gocui.View, changeViewLine int) bool {
|
||||
return self.c.UserConfig().Gui.UseHunkModeInStagingView &&
|
||||
!self.IsSingleHunkForWholeFile(view, changeViewLine)
|
||||
}
|
||||
|
||||
// ShowSelectionAtLine moves the focused main view's selection to the given view line,
|
||||
// clamped to the content. scrollIntoView scrolls the line into view when it's
|
||||
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
|
||||
// screen already.
|
||||
func (self *DiffLineHelper) ShowSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
|
||||
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
|
||||
|
||||
// A search carries on from where the selection now is, so that stepping to the
|
||||
// next match goes to the one after it rather than the one after the match the
|
||||
// user last stepped to.
|
||||
view.SetNearestSearchPosition()
|
||||
}
|
||||
|
||||
// SelectChangeBlock selects the whole change block around the given change line, for
|
||||
// hunk mode: the cursor goes to the block's first line and the range anchor to its
|
||||
// last, so the native range highlight spans the block. With no block to be found —
|
||||
// a diff with no changes in it — it falls back to a single-line selection.
|
||||
//
|
||||
// scrollIntoView brings the block's first line on screen, for the commands that mean
|
||||
// to go there; a click leaves it false, so that the view doesn't move under the mouse
|
||||
// when the block the click landed in starts above the viewport.
|
||||
func (self *DiffLineHelper) SelectChangeBlock(
|
||||
pane types.DiffPaneContext, changeViewLine int, scrollIntoView bool,
|
||||
) {
|
||||
view := pane.GetView()
|
||||
start, end, ok := self.ChangeBlockBounds(view, changeViewLine)
|
||||
if !ok {
|
||||
pane.DiffSelectState().Mode = types.DiffSelectModeLine
|
||||
view.CancelRangeSelect()
|
||||
self.ShowSelectionAtLine(view, changeViewLine, scrollIntoView)
|
||||
return
|
||||
}
|
||||
view.SetRangeSelectStart(end)
|
||||
self.ShowSelectionAtLine(view, start, scrollIntoView)
|
||||
}
|
||||
|
||||
// SelectedHunkBounds returns the change block selected in hunk mode. The range
|
||||
// anchor stays on the block's far end when a click moves the cursor before its
|
||||
// handler runs, so it still identifies the selected block.
|
||||
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
|
||||
anchor := view.RangeSelectStartY()
|
||||
if anchor < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return self.ChangeBlockBounds(view, anchor)
|
||||
}
|
||||
|
||||
// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which
|
||||
// say which of its lines are in the custom patch being built from it.
|
||||
//
|
||||
// They are shown while the focused main view holds the focus — either of its panes, so
|
||||
// that moving between the diff and the patch previewed beside it doesn't make them come
|
||||
// and go — and only over a diff a patch is being built from: a patch built from some
|
||||
// other commit says nothing about the lines of this one.
|
||||
//
|
||||
// Call it whenever either of those can have changed: as a pane's content settles, when
|
||||
// the focus arrives or leaves, and when the patch itself changes.
|
||||
func (self *DiffLineHelper) RefreshInclusionGutter() {
|
||||
view := self.c.Contexts().Normal.GetView()
|
||||
|
||||
included := self.patchInclusion()
|
||||
if included == nil {
|
||||
view.SetInclusionGutter(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
marks := make([]bool, len(resolved))
|
||||
showsChanges := false
|
||||
for i, row := range resolved {
|
||||
if !row.ok || !row.info.IsChange() {
|
||||
continue
|
||||
}
|
||||
showsChanges = true
|
||||
marks[i] = included(row.info)
|
||||
}
|
||||
|
||||
// Nothing to mark and nowhere to mark it: the pane is showing a message rather than
|
||||
// a diff, or a diff with nothing in it.
|
||||
if !showsChanges {
|
||||
view.SetInclusionGutter(false, nil)
|
||||
return
|
||||
}
|
||||
view.SetInclusionGutter(true, marks)
|
||||
}
|
||||
|
||||
// patchInclusion asks the panel whose diff the focused main view is showing which of
|
||||
// that diff's lines are in the custom patch being built from it, and answers nil where
|
||||
// there is no such patch — including when the focus is elsewhere, the marks being an
|
||||
// affordance of the focused view.
|
||||
func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool {
|
||||
if !self.mainViewIsFocused() {
|
||||
return nil
|
||||
}
|
||||
// The panel beneath is found from the pane that holds the focus, which is not always
|
||||
// the one the diff is in: moving to the pane beside it takes the other off the stack.
|
||||
sidePanel := self.c.Context().NextInStack(self.c.Context().CurrentStatic())
|
||||
if sidePanel == nil {
|
||||
return nil
|
||||
}
|
||||
actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return actions.PatchInclusion()
|
||||
}
|
||||
|
||||
// ShowsCustomPatch reports whether the given view is the one previewing the custom patch
|
||||
// being built, which is the lower pane while a patch is being built from the diff in the
|
||||
// upper one.
|
||||
func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool {
|
||||
return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil
|
||||
}
|
||||
@@ -46,8 +46,16 @@ func (self *FilesHelper) EditFileAtLineAndWait(filename string, lineNumber int)
|
||||
|
||||
// Always suspend, regardless of the value of the suspend config,
|
||||
// since we want to prevent interacting with the UI until the editor
|
||||
// returns, even if the editor doesn't use the terminal
|
||||
return self.callEditor(cmdStr, true)
|
||||
// returns, even if the editor doesn't use the terminal.
|
||||
//
|
||||
// And nothing is refreshed here; that is why this doesn't go through
|
||||
// callEditor. The editor was handed a patch we wrote for it, so the repo
|
||||
// hasn't changed when it returns; it changes when the caller applies what
|
||||
// came back. A refresh in between reads the state from before that, and
|
||||
// then races the caller's own refresh to publish it.
|
||||
_, err = self.c.RunSubprocess(
|
||||
self.c.OS().Cmd.NewShell(cmdStr, self.c.UserConfig().OS.ShellFunctionsFile))
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *FilesHelper) OpenDirInEditor(path string) error {
|
||||
|
||||
@@ -3,6 +3,8 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -41,7 +43,10 @@ func (self *PatchBuildingHelper) Escape() {
|
||||
func (self *PatchBuildingHelper) Reset() error {
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
|
||||
if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
|
||||
// The patch-building view is the one thing with nothing left to show once the patch is
|
||||
// gone, so it is the one thing left behind. Everywhere else the user is looking at
|
||||
// something of their own — a commit's diff, the working tree's — which is still there.
|
||||
if self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() {
|
||||
self.Escape()
|
||||
}
|
||||
|
||||
@@ -49,8 +54,11 @@ func (self *PatchBuildingHelper) Reset() error {
|
||||
Scope: []types.RefreshableView{types.COMMIT_FILES},
|
||||
})
|
||||
|
||||
// refreshing the current context so that the secondary panel is hidden if necessary.
|
||||
self.c.PostRefreshUpdate(self.c.Context().Current())
|
||||
// Render again so that the pane that was previewing the patch goes with it. The
|
||||
// panel asked to do that is the side panel rather than whichever context has the
|
||||
// focus. Both main panes are rendered by the panel beneath them, so a reset from
|
||||
// within the focused main view has to go through that panel too.
|
||||
self.c.PostRefreshUpdate(self.c.Context().CurrentSide())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -73,7 +81,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
|
||||
|
||||
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true)
|
||||
diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, git_commands.DiffModePlain)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -589,7 +589,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
||||
// this runs — and the keys buffered during the refresh replay —
|
||||
// the refreshed state is in place.
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.c.GocuiGui().EndBlockingEvents()
|
||||
self.c.GocuiGui().EndBlockingEvents()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1085,17 +1086,31 @@ type capturedCommitFilesState struct {
|
||||
from string
|
||||
to string
|
||||
reverse bool
|
||||
// Whether there is a commit to load the files of at all. The panel is only ever
|
||||
// pointed at one by being entered, and a patch can now be built from a commit's diff
|
||||
// without that — after which anything that refreshes the panel would otherwise be
|
||||
// asking for the files of nothing.
|
||||
hasCommit bool
|
||||
}
|
||||
|
||||
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
|
||||
// an immutable snapshot. It must run on the UI thread.
|
||||
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
|
||||
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
|
||||
commitFilesContext := self.c.Contexts().CommitFiles
|
||||
if commitFilesContext.GetRef() == nil && commitFilesContext.GetRefRange() == nil {
|
||||
return capturedCommitFilesState{}
|
||||
}
|
||||
|
||||
from, to := commitFilesContext.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
|
||||
return capturedCommitFilesState{from: from, to: to, reverse: reverse, hasCommit: true}
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
|
||||
if !captured.hasCommit {
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -53,8 +54,8 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
|
||||
return
|
||||
}
|
||||
|
||||
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, false)
|
||||
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, true, true)
|
||||
mainDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, false)
|
||||
secondaryDiff := self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, true)
|
||||
|
||||
// grabbing locks here and releasing before we finish the function
|
||||
// because pushing say the secondary context could mean entering this function
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -722,17 +722,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
||||
}
|
||||
}
|
||||
|
||||
// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane
|
||||
// beside the diff it is being built from, as a diff of the two trees the patch is
|
||||
// materialized into. This way it is shown by whatever renders the rest of the diffs,
|
||||
// and its lines can be pointed at and taken back out of the patch.
|
||||
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
|
||||
if c.Git().Patch.PatchBuilder.Active() {
|
||||
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
|
||||
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewRenderStringWithoutScrollTask(patch),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
if !c.Git().Patch.PatchBuilder.Active() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// A render of the same patch reuses the trees; only a change to the patch writes them
|
||||
// again.
|
||||
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
// The same mode as the diff beside it: both panes of the pair have to agree about
|
||||
// whether what they show can be acted on.
|
||||
mode := c.Helpers().DiffLine.MainViewDiffMode()
|
||||
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode)
|
||||
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
||||
|
||||
@@ -79,6 +79,48 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.primaryAction,
|
||||
// The description is of the working tree's diff, which is where the key does
|
||||
// the thing users know it for; over a commit's diff it says so for itself.
|
||||
Description: self.c.Tr.Stage,
|
||||
DescriptionFunc: self.diffActionDescription(self.c.Tr.Stage, self.c.Tr.ToggleSelectionForPatch),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.StageSelectionTooltip,
|
||||
// Over a commit's diff the key toggles lines in the custom patch, which the
|
||||
// description says for itself; there is nothing to add to it.
|
||||
TooltipFunc: self.diffActionDescription(self.c.Tr.StageSelectionTooltip, ""),
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
||||
Handler: self.discardSelection,
|
||||
Description: self.c.Tr.DiscardSelection,
|
||||
DescriptionFunc: self.diffActionDescription(self.c.Tr.DiscardSelection, self.c.Tr.RemoveSelectionFromPatch),
|
||||
GetDisabledReason: self.discardSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.DiscardSelectionTooltip,
|
||||
// Over a commit's diff the key rewrites the commit rather than touching the
|
||||
// index, which is worth the warning the other tooltip carries.
|
||||
TooltipFunc: self.diffActionDescription(
|
||||
self.c.Tr.DiscardSelectionTooltip, self.c.Tr.RemoveSelectionFromPatchTooltip),
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk),
|
||||
Handler: self.editHunk,
|
||||
Description: self.c.Tr.EditHunk,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.EditHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.EditHunkTooltip,
|
||||
},
|
||||
{
|
||||
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,
|
||||
@@ -139,6 +181,32 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.CommitChanges),
|
||||
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitPress),
|
||||
Description: self.c.Tr.Commit,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Commit),
|
||||
Tooltip: self.c.Tr.CommitTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook),
|
||||
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleWIPCommitPress),
|
||||
Description: self.c.Tr.CommitChangesWithoutHook,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithoutHook),
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor),
|
||||
Handler: self.workingTreeAction(self.c.Helpers().WorkingTree.HandleCommitEditorPress),
|
||||
Description: self.c.Tr.CommitChangesWithEditor,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.CommitChangesWithEditor),
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup),
|
||||
Handler: self.workingTreeAction(self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress),
|
||||
Description: self.c.Tr.FindBaseCommitForFixup,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.FindBaseCommitForFixup),
|
||||
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
|
||||
},
|
||||
{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},
|
||||
@@ -180,6 +248,14 @@ func (self *MainViewController) Context() types.Context {
|
||||
return self.context
|
||||
}
|
||||
|
||||
// GetOnFocus brings on the marks over the lines that are in the custom patch, which
|
||||
// are an affordance of the focused view, so they arrive with the focus.
|
||||
func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) {
|
||||
return func(types.OnFocusOpts) {
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) togglePanel() error {
|
||||
if !self.otherContext.GetView().Visible {
|
||||
return nil
|
||||
@@ -191,7 +267,7 @@ func (self *MainViewController) togglePanel() error {
|
||||
isDiff := self.isDiffView()
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if isDiff {
|
||||
establishDiffSelection(self.c, self.otherContext, -1)
|
||||
self.c.Helpers().DiffLine.EstablishSelection(self.otherContext, -1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -248,6 +324,143 @@ func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// diffSource returns the panel beneath the focused main view, as the thing that can
|
||||
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
|
||||
// panel beneath shows no diff.
|
||||
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return nil
|
||||
}
|
||||
sidePanel := self.c.Context().NextInStack(self.context)
|
||||
if sidePanel == nil {
|
||||
return nil
|
||||
}
|
||||
return sidePanel.GetFocusedMainViewDiffSource()
|
||||
}
|
||||
|
||||
// focusedMainViewActions returns what the panel beneath the focused main view does to
|
||||
// a selection in its diff, or nil where it does nothing to it — a panel whose diff can
|
||||
// be read and copied but not acted on.
|
||||
func (self *MainViewController) focusedMainViewActions() types.FocusedMainViewActions {
|
||||
actions, _ := self.diffSource().(types.FocusedMainViewActions)
|
||||
return actions
|
||||
}
|
||||
|
||||
// primaryAction acts on the selected diff lines, leaving what that means to the panel
|
||||
// beneath — which also re-renders the diff, since it is the one that changed it.
|
||||
func (self *MainViewController) primaryAction() error {
|
||||
actions := self.focusedMainViewActions()
|
||||
if actions == nil {
|
||||
return nil
|
||||
}
|
||||
first, last := self.context.GetView().SelectedLineRange()
|
||||
return actions.PrimaryAction(self.context, first, last)
|
||||
}
|
||||
|
||||
// discardSelection takes the selected diff lines back out of what they are part of,
|
||||
// which — like the primary action — is the panel's business, and so is the re-render
|
||||
// that follows.
|
||||
func (self *MainViewController) discardSelection() error {
|
||||
actions := self.focusedMainViewActions()
|
||||
if actions == nil {
|
||||
return nil
|
||||
}
|
||||
first, last := self.context.GetView().SelectedLineRange()
|
||||
return actions.DiscardSelection(self.context, first, last)
|
||||
}
|
||||
|
||||
// editHunk hands the hunk around the selection to an editor, and is only offered over
|
||||
// the working tree's diff: what comes back is applied to the index, which is not
|
||||
// something a commit's diff has any use for.
|
||||
func (self *MainViewController) editHunk() error {
|
||||
actions, ok := self.diffSource().(*WorkingTreeDiffActions)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
first, last := self.context.GetView().SelectedLineRange()
|
||||
return actions.EditHunk(self.context, first, last)
|
||||
}
|
||||
|
||||
// workingTreeAction wraps a command that acts on the working tree — committing, finding
|
||||
// the commit to fix up — so that it only runs while the focused main view is showing the
|
||||
// working tree's diff. Over a commit's diff the key does nothing, so that browsing
|
||||
// through history can't commit by accident. The check is per press, since what the main
|
||||
// view shows changes as the user moves around while the keybindings are registered once.
|
||||
func (self *MainViewController) workingTreeAction(action func() error) func() error {
|
||||
return func() error {
|
||||
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
|
||||
return nil
|
||||
}
|
||||
return action()
|
||||
}
|
||||
}
|
||||
|
||||
// workingTreeActionDescription gives a command's description only where the command
|
||||
// applies — over the working tree's diff — so that it is listed there and nowhere else.
|
||||
func (self *MainViewController) workingTreeActionDescription(description string) func() string {
|
||||
return self.diffActionDescription(description, "")
|
||||
}
|
||||
|
||||
// diffActionDescription describes a command in the words that suit the diff it applies
|
||||
// to: acting on the working tree's diff stages, acting on a commit's builds a custom
|
||||
// patch. Over content that is no diff at all the command doesn't apply, and describes
|
||||
// itself as nothing, which keeps it out of the keybindings menu there.
|
||||
func (self *MainViewController) diffActionDescription(staging string, patchBuilding string) func() string {
|
||||
return func() string {
|
||||
switch self.diffMainViewType() {
|
||||
case types.DiffMainViewTypeStaging:
|
||||
return staging
|
||||
case types.DiffMainViewTypePatchBuilding:
|
||||
return patchBuilding
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -283,6 +496,19 @@ func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledRea
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardSelectionDisabledReason disables discarding while there is nothing to discard,
|
||||
// and where the panel beneath won't have it: taking lines out of a commit means
|
||||
// rewriting it, which isn't always something we may do.
|
||||
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
|
||||
if reason := self.diffSelectionDisabledReason(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
if actions := self.focusedMainViewActions(); actions != nil {
|
||||
return actions.DiscardSelectionDisabledReason(self.context)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
@@ -346,6 +572,10 @@ func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
|
||||
self.draggingWithMouse = false
|
||||
self.c.GocuiGui().CancelMouseCapture()
|
||||
}
|
||||
// Where the focus has gone is already known here, so asking again keeps the
|
||||
// patch marks over a move to the pane beside this one, and takes them away
|
||||
// when the focus leaves the pair.
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +630,7 @@ func (self *MainViewController) selectClickedDiffLine(viewLine int) {
|
||||
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
|
||||
viewLine >= start && viewLine <= end {
|
||||
self.context.ResetDiffSelectMode()
|
||||
showSelectionAtLine(view, viewLine, false)
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
|
||||
return
|
||||
}
|
||||
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
|
||||
@@ -409,122 +639,11 @@ func (self *MainViewController) selectClickedDiffLine(viewLine int) {
|
||||
}
|
||||
}
|
||||
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()
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(view, viewLine, false)
|
||||
}
|
||||
|
||||
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)
|
||||
self.c.Helpers().DiffLine.SelectChangeBlock(self.context, changeViewLine, scrollIntoView)
|
||||
}
|
||||
|
||||
// navigate moves the focused main view to the row find locates from the current
|
||||
@@ -623,7 +742,7 @@ func (self *MainViewController) placeNavigationTarget(target int, alignTop bool)
|
||||
}
|
||||
// Line mode leaves a single-line selection at the target; an active range extends
|
||||
// to it, the anchor being untouched.
|
||||
showSelectionAtLine(v, target, true)
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
|
||||
}
|
||||
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
@@ -655,7 +774,7 @@ func (self *MainViewController) moveCursor(delta int) {
|
||||
} else if delta == 1 {
|
||||
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
|
||||
}
|
||||
showSelectionAtLine(v, after, true)
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, after, true)
|
||||
}
|
||||
|
||||
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
|
||||
@@ -702,7 +821,7 @@ func (self *MainViewController) adjustSelection(delta int) {
|
||||
// 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)
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(self.context.GetView(), target, true)
|
||||
}
|
||||
|
||||
// selectingRange reports whether a range selection is currently active: we're in
|
||||
|
||||
@@ -27,7 +27,7 @@ func (self *OptionsMenuAction) Call() error {
|
||||
if binding.GetDisabledReason != nil {
|
||||
disabledReason = binding.GetDisabledReason()
|
||||
}
|
||||
tooltip := binding.Tooltip
|
||||
tooltip := binding.GetTooltip()
|
||||
if len(binding.Keys) > 1 {
|
||||
if tooltip != "" {
|
||||
tooltip += "\n\n"
|
||||
|
||||
@@ -10,6 +10,9 @@ type ReflogCommitsController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*models.Commit]
|
||||
c *ControllerCommon
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
var _ types.IController = &ReflogCommitsController{}
|
||||
@@ -17,7 +20,7 @@ var _ types.IController = &ReflogCommitsController{}
|
||||
func NewReflogCommitsController(
|
||||
c *ControllerCommon,
|
||||
) *ReflogCommitsController {
|
||||
return &ReflogCommitsController{
|
||||
controller := &ReflogCommitsController{
|
||||
baseController: baseController{},
|
||||
ListControllerTrait: NewListControllerTrait(
|
||||
c,
|
||||
@@ -27,6 +30,19 @@ func NewReflogCommitsController(
|
||||
),
|
||||
c: c,
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, c.Contexts().ReflogCommits, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the reflog entry the panel has selected, whose diff its main view
|
||||
// shows. A reflog entry is never a commit of the checked-out branch as far as we are
|
||||
// concerned, so nothing here may be rewritten.
|
||||
func (self *ReflogCommitsController) diffTarget() *commitDiffTarget {
|
||||
commit := self.context().GetSelected()
|
||||
if commit == nil {
|
||||
return nil
|
||||
}
|
||||
return &commitDiffTarget{from: commit.ParentRefName(), to: commit.RefName()}
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) Context() types.Context {
|
||||
@@ -37,6 +53,10 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
|
||||
return self.c.Contexts().ReflogCommits
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
return func() {
|
||||
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
||||
@@ -45,9 +65,10 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
if commit == nil {
|
||||
task = types.NewRenderStringTask("No reflog history")
|
||||
} else {
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit))
|
||||
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), mode)
|
||||
|
||||
task = types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
task = types.NewMainViewDiffTask(cmdObj.GetCmd(), mode)
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
@@ -56,6 +77,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
Title: "Reflog Entry",
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,10 +92,12 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
if stashEntry == nil {
|
||||
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
|
||||
} else {
|
||||
mode := self.c.Helpers().DiffLine.MainViewDiffMode()
|
||||
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
|
||||
task = types.NewRunDiffRendererTaskWithPrefix(
|
||||
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
|
||||
task = types.NewMainViewDiffTaskWithPrefix(
|
||||
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(),
|
||||
prefix,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +108,7 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
@@ -123,7 +125,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
|
||||
if file == nil {
|
||||
task = types.NewRenderStringTask(prefix)
|
||||
} else {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, git_commands.DiffModeRendered, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
|
||||
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -24,17 +25,48 @@ type SwitchToDiffFilesController struct {
|
||||
baseController
|
||||
c *ControllerCommon
|
||||
context CanSwitchToDiffFiles
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
func NewSwitchToDiffFilesController(
|
||||
c *ControllerCommon,
|
||||
context CanSwitchToDiffFiles,
|
||||
) *SwitchToDiffFilesController {
|
||||
return &SwitchToDiffFilesController{
|
||||
controller := &SwitchToDiffFilesController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
context: context,
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, context, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the commit — or stash entry, or range of commits — the panel has
|
||||
// selected, whose whole diff its main view shows.
|
||||
func (self *SwitchToDiffFilesController) diffTarget() *commitDiffTarget {
|
||||
ref := self.context.GetSelectedRef()
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
refRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
from, to := context.FromAndToForDiff(ref, refRange)
|
||||
return &commitDiffTarget{from: from, to: to, canRebase: self.canRebase(ref, refRange)}
|
||||
}
|
||||
|
||||
// canRebase reports whether the given selection is one lazygit may rewrite: the panel
|
||||
// has to allow it in the first place, a range of commits can't be rewritten as one,
|
||||
// and in diffing mode what the main view shows is a diff against another ref rather
|
||||
// than the commit itself, unless that other ref is the selected commit.
|
||||
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refRange *types.RefRange) bool {
|
||||
if !self.context.CanRebase() {
|
||||
return false
|
||||
}
|
||||
if self.c.Modes().Diffing.Active() {
|
||||
return self.c.Modes().Diffing.Ref == ref.RefName()
|
||||
}
|
||||
return refRange == nil
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -64,21 +96,16 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) enter() error {
|
||||
ref := self.context.GetSelectedRef()
|
||||
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
commitFilesContext := self.c.Contexts().CommitFiles
|
||||
|
||||
canRebase := self.context.CanRebase()
|
||||
if canRebase {
|
||||
if self.c.Modes().Diffing.Active() {
|
||||
if self.c.Modes().Diffing.Ref != ref.RefName() {
|
||||
canRebase = false
|
||||
}
|
||||
} else if refsRange != nil {
|
||||
canRebase = false
|
||||
}
|
||||
}
|
||||
canRebase := self.canRebase(ref, refsRange)
|
||||
|
||||
commitFilesContext.ClearFilter()
|
||||
commitFilesContext.ReInit(ref, refsRange)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -70,16 +71,35 @@ func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewM
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
|
||||
return self.focusMainView(self.c.Contexts().Normal, -1)
|
||||
// Usually the main pane, but the content can be in the secondary one alone: a file
|
||||
// with nothing but staged changes shows them there.
|
||||
mainViewContext := self.c.Contexts().Normal
|
||||
if self.c.State().GetRepoState().GetMainPanes() == types.SecondaryPaneOnly {
|
||||
mainViewContext = self.c.Contexts().NormalSecondary
|
||||
}
|
||||
return self.focusMainView(mainViewContext, -1)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
|
||||
mainViewContext.ClearSearchString()
|
||||
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
|
||||
|
||||
if _, ok := self.context.(types.DiffMainViewContext); ok {
|
||||
establishDiffSelection(self.c, mainViewContext, clickedLineIdx)
|
||||
if _, ok := self.context.(types.DiffMainViewContext); !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The diff on screen was produced for reading, and the renderer that produced it may
|
||||
// have laid it out in a way that says nothing about which line of which file each row
|
||||
// is. Now that the user wants to act on it, it is re-rendered as git's own diff — the
|
||||
// panel below decides that for itself, from the same question — and the selection
|
||||
// goes on that instead of on rows we can't place.
|
||||
if self.c.Helpers().DiffLine.MainViewDiffMode() == git_commands.DiffModeRaw {
|
||||
self.c.Helpers().DiffLine.RenderFocusedMainViewAgain(mainViewContext.GetView(), self.context, func() {
|
||||
self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// 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.FocusedMainViewActions = &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, git_commands.DiffModePlain, 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()
|
||||
}
|
||||
|
||||
// PrimaryAction stages the selected diff lines, or takes them back out of the index
|
||||
// when what is selected is the staged side of the diff.
|
||||
func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Either way the patch goes to the index: forwards from the unstaged side to stage
|
||||
// it, backwards from the staged side to take it back out.
|
||||
return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
|
||||
}
|
||||
|
||||
// DiscardSelection takes the selected diff lines out of the working tree — or, on the
|
||||
// staged side, out of the index, which is where "discard this" means "I don't want it
|
||||
// staged".
|
||||
func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int) error {
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToDiscard,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Either way the change is applied backwards; which side it is applied to decides
|
||||
// how destructive that is. On the staged side it goes to the index; this is the
|
||||
// same as unstaging, so nothing is lost. On the unstaged side it goes to the
|
||||
// working tree, where the change is gone for good, so we ask first.
|
||||
return self.c.ConfirmIf(!onStagedSide && !self.c.UserConfig().Gui.SkipDiscardChangeWarning,
|
||||
types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardChangeTitle,
|
||||
Prompt: self.c.Tr.DiscardChangePrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.applyDiffLineSelection(pane, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason is nil: a change of the working tree can always be
|
||||
// thrown away, and one in the index always taken back out of it.
|
||||
func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPaneContext) *types.DisabledReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditHunk opens the git hunk holding the selection in an editor, as a patch against
|
||||
// the index, and applies whatever comes back. It is how you stage something the diff
|
||||
// can't express — half of a changed line, or a change written differently from either
|
||||
// side — since what the editor hands back is applied rather than matched against the
|
||||
// file's own diff.
|
||||
//
|
||||
// The hunk is the git one, context and all, rather than lazygit's block of adjacent
|
||||
// changes: an editable patch is one that still applies, and the context lines are what
|
||||
// let git place it.
|
||||
func (self *WorkingTreeDiffActions) EditHunk(
|
||||
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
|
||||
) error {
|
||||
infos, onStagedSide, ok := self.diffLineSelection(pane, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
file := self.fileForDiffLinePath(infos[0].Path)
|
||||
if file == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, onStagedSide))
|
||||
lineIndices := patch.ChangeLineIndicesForLines(parsedPatch,
|
||||
[]patch.LineIdentity{infos[0].PatchLineIdentity()})
|
||||
if len(lineIndices) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hunkIdx := parsedPatch.HunkContainingLine(lineIndices[0])
|
||||
hunkStartIdx := parsedPatch.HunkStartIdx(hunkIdx)
|
||||
patchText := parsedPatch.
|
||||
Transform(patch.TransformOpts{
|
||||
Reverse: onStagedSide,
|
||||
IncludedLineIndices: patch.ExpandRange(hunkStartIdx, parsedPatch.HunkEndIdx(hunkIdx)),
|
||||
FileNameOverride: file.GetPath(),
|
||||
}).
|
||||
FormatPlain()
|
||||
|
||||
patchFilepath, err := self.c.Git().Patch.SaveTemporaryPatch(patchText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The patch is written with a two-line header before its hunk, so the line the
|
||||
// user was on sits that much further down the file they are about to edit.
|
||||
const headerLineCount = 2
|
||||
if err := self.c.Helpers().Files.EditFileAtLineAndWait(patchFilepath,
|
||||
lineIndices[0]-hunkStartIdx+headerLineCount+1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
editedPatchText, err := self.c.Git().File.Cat(patchFilepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
|
||||
|
||||
// Everything the editor left behind is taken, this being a patch the user wrote
|
||||
// rather than a selection out of one of ours.
|
||||
lineCount := strings.Count(editedPatchText, "\n") + 1
|
||||
newPatchText := patch.
|
||||
Parse(editedPatchText).
|
||||
Transform(patch.TransformOpts{
|
||||
IncludedLineIndices: patch.ExpandRange(0, lineCount),
|
||||
FileNameOverride: file.GetPath(),
|
||||
}).
|
||||
FormatPlain()
|
||||
|
||||
if err := self.c.Git().Patch.ApplyPatch(newPatchText, git_commands.ApplyPatchOpts{
|
||||
Reverse: onStagedSide,
|
||||
Cached: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Block input until the refresh has landed, as the staging commands do: the diff is
|
||||
// about to be rebuilt from a file that no longer looks the way it did.
|
||||
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchInclusion is nil: a custom patch is built from a commit's diff, never from the
|
||||
// working tree's, so no line of this diff is ever in one.
|
||||
func (self *WorkingTreeDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
// diffLineSelection resolves what the user has selected in a pane of the focused main
|
||||
// view to the change lines to act on, and reports whether they are the staged side of
|
||||
// the diff — which is a question about the pane, so it is the same for every file of a
|
||||
// directory's diff. ok is false when the selection holds no change line, in which case
|
||||
// there is nothing to act on.
|
||||
func (self *WorkingTreeDiffActions) diffLineSelection(
|
||||
pane types.DiffPaneContext, firstLineIdx int, lastLineIdx int,
|
||||
) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
|
||||
infos = self.c.Helpers().DiffLine.ChangeLinesInViewRange(pane.GetView(), firstLineIdx, lastLineIdx)
|
||||
if len(infos) == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
return infos, self.showsStagedSide(pane), true
|
||||
}
|
||||
|
||||
// applyDiffLineSelection applies the selected change lines, a patch per file, and
|
||||
// re-renders what that changed. onStagedSide says which of the file's two diffs the
|
||||
// lines were selected in and so are to be found in; opts says how to apply them.
|
||||
// firstLineIdx is where the selection started, which is where the work carries on from
|
||||
// once the diff has changed under it.
|
||||
func (self *WorkingTreeDiffActions) applyDiffLineSelection(
|
||||
pane types.DiffPaneContext, firstLineIdx int,
|
||||
infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts,
|
||||
) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
|
||||
|
||||
// A directory's diff spans several files, and a patch is of one file, so the
|
||||
// selected lines are grouped by the file they belong to and applied file by file.
|
||||
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
|
||||
acted := set.New[string]()
|
||||
actedSideRemains := false
|
||||
for path, fileInfos := range infosByFile {
|
||||
file := self.fileForDiffLinePath(path)
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
changesLeft, err := self.applyDiffLines(file, fileInfos, onStagedSide, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acted.Add(file.GetPath())
|
||||
actedSideRemains = actedSideRemains || changesLeft
|
||||
}
|
||||
if !actedSideRemains {
|
||||
actedSideRemains = self.anyFileHasChangesOnSide(acted, onStagedSide)
|
||||
}
|
||||
|
||||
// Whether the other side has anything decides which pane the work carries on in.
|
||||
// If the lines were staged, they are in the index now, so that side has them. If
|
||||
// they were discarded, the other side was not touched, so the model still
|
||||
// describes it correctly.
|
||||
otherSideHasChanges := opts.Cached || self.anyFileHasChangesOnSide(set.New[string](), !onStagedSide)
|
||||
|
||||
// The refresh below queues the re-render of the diff we just changed; this rides it,
|
||||
// so that the selection ends up on the change that took the place of the one acted
|
||||
// on rather than at a position that means nothing any more — and in the pane the
|
||||
// work carries on in, which is not always the one it was in.
|
||||
self.revealSelectionInPaneItLandsIn(pane, firstLineIdx, actedSideRemains, otherSideHasChanges)
|
||||
|
||||
// Block input until the refresh has landed, so that a quick second keypress acts on
|
||||
// the diff as it now is rather than on the one we just changed.
|
||||
self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileForDiffLinePath maps the absolute path a diff line carries to the working tree
|
||||
// file it belongs to, or nil for a path that is no file of this repo's working tree.
|
||||
func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.File {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
|
||||
}
|
||||
|
||||
// applyDiffLines applies the given change lines of one file — a line, a hunk, a range —
|
||||
// as a patch built from that file's own diff:
|
||||
//
|
||||
// - stage: read the unstaged diff, apply it to the index
|
||||
// - unstage: read the staged diff, apply it to the index backwards
|
||||
//
|
||||
// sourceCached names the diff the lines were selected in, which is where they are found
|
||||
// again; opts says how to apply what is built from them. The two are independent — a
|
||||
// discard reads one side and reverses it — so they are passed separately.
|
||||
//
|
||||
// Each selected line is looked for by where it sits in the file. This tells the two
|
||||
// halves of a modified line apart: the deletion and the addition replacing it share a
|
||||
// position in the new file and differ only in being a deletion. Context lines are not
|
||||
// selected: a patch of the lines you picked keeps whatever context it needs around
|
||||
// them by itself.
|
||||
//
|
||||
// It reports whether the diff it read holds changes the selection didn't cover. The
|
||||
// caller uses this to tell whether the side acted on still has anything of this file
|
||||
// in it once we are done.
|
||||
func (self *WorkingTreeDiffActions) applyDiffLines(
|
||||
file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts,
|
||||
) (bool, error) {
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached))
|
||||
|
||||
patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch,
|
||||
lo.Map(infos, func(info types.DiffLineInfo, _ int) patch.LineIdentity {
|
||||
return info.PatchLineIdentity()
|
||||
}))
|
||||
|
||||
changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch)
|
||||
|
||||
// Acting on every change of a file is acting on the file itself, and saying so is
|
||||
// not the same as applying its diff. The diff of a deleted file is its content
|
||||
// going away, and putting that into the index line by line leaves an empty file
|
||||
// there rather than the deletion; the diff of an added one is its whole content,
|
||||
// and taking that back out leaves an empty file in the index rather than an
|
||||
// untracked one.
|
||||
if !changesLeft && opts.Cached {
|
||||
if opts.Reverse {
|
||||
return false, self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked)
|
||||
}
|
||||
return false, self.c.Git().WorkingTree.StageFile(file.GetPath())
|
||||
}
|
||||
|
||||
patchToApply := parsedPatch.
|
||||
Transform(patch.TransformOpts{
|
||||
Reverse: opts.Reverse,
|
||||
IncludedLineIndices: patchLineIndices,
|
||||
FileNameOverride: file.GetPath(),
|
||||
}).
|
||||
FormatPlain()
|
||||
if patchToApply == "" {
|
||||
return changesLeft, nil
|
||||
}
|
||||
|
||||
return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts)
|
||||
}
|
||||
|
||||
// changeLineCount returns how many of a patch's lines are changes rather than context
|
||||
// or header. A selection of the whole diff covers exactly that many.
|
||||
func changeLineCount(p *patch.Patch) int {
|
||||
return lo.CountBy(p.Lines(), func(line *patch.PatchLine) bool {
|
||||
return line.IsAddition() || line.IsDeletion()
|
||||
})
|
||||
}
|
||||
|
||||
// revealSelectionInPaneItLandsIn arranges for the selection to carry on where the work
|
||||
// does, which is not always the pane it was in.
|
||||
//
|
||||
// Each side of the diff has a pane of its own, so acting on one usually leaves
|
||||
// everything where it is. But a pane is only shown while its side has something in it:
|
||||
// staging the last unstaged change takes the upper pane away, and unstaging the last
|
||||
// staged one takes the lower one away. The refresh moves the focus into whichever pane
|
||||
// is left, and this puts the selection there to meet it — on the lines just acted on,
|
||||
// which are in that pane now, unless they were discarded rather than moved, in which
|
||||
// case on what is left of the file.
|
||||
func (self *WorkingTreeDiffActions) revealSelectionInPaneItLandsIn(
|
||||
pane types.DiffPaneContext, firstLineIdx int, actedSideRemains bool, otherSideHasChanges bool,
|
||||
) {
|
||||
target := pane
|
||||
if !actedSideRemains && otherSideHasChanges {
|
||||
target = self.otherPane(pane)
|
||||
}
|
||||
|
||||
// Hold input back until the selection is on the change the work carries on from. The
|
||||
// refresh holds it until the model is up to date, but the diff is re-rendered after
|
||||
// that, and until it has been the selection is still on lines that aren't there any
|
||||
// more — so a key pressed meanwhile would act on nothing.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, target, firstLineIdx, 0,
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
}
|
||||
|
||||
// otherPane returns the main pane that isn't the given one.
|
||||
func (self *WorkingTreeDiffActions) otherPane(pane types.DiffPaneContext) types.DiffPaneContext {
|
||||
if pane.GetKey() == self.c.Contexts().Normal.GetKey() {
|
||||
return self.c.Contexts().NormalSecondary
|
||||
}
|
||||
return self.c.Contexts().Normal
|
||||
}
|
||||
|
||||
// anyFileHasChangesOnSide reports whether any file under the selected node, other than
|
||||
// the ones named by except, has changes on the given side of the index, as the model
|
||||
// has them. The model is right about any file the action didn't touch; the ones it did
|
||||
// touch report for themselves, their entry not being right until the refresh lands.
|
||||
func (self *WorkingTreeDiffActions) anyFileHasChangesOnSide(except *set.Set[string], staged bool) bool {
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
found := false
|
||||
_ = node.ForEachFile(func(file *models.File) error {
|
||||
if except.Includes(file.GetPath()) {
|
||||
return nil
|
||||
}
|
||||
if (staged && file.HasStagedChanges) || (!staged && file.HasUnstagedChanges) {
|
||||
found = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
+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 {
|
||||
|
||||
@@ -185,6 +185,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View
|
||||
return self.gui.getViewBufferManagerForView(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
|
||||
return self.gui.getManager(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
|
||||
self.gui.readLinesToFillView(view)
|
||||
}
|
||||
|
||||
+252
-11
@@ -1,9 +1,12 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
)
|
||||
|
||||
func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
|
||||
@@ -65,6 +68,10 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
|
||||
|
||||
view.Subtitle = opts.SubTitle
|
||||
|
||||
if mainContext := gui.mainContextForView(view); mainContext != nil {
|
||||
mainContext.SetContentIsDiff(types.ContentIsDiff(opts.Task))
|
||||
}
|
||||
|
||||
if err := gui.runTaskForView(view, opts.Task); err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
}
|
||||
@@ -108,10 +115,21 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
panes := mainPanesFor(opts)
|
||||
|
||||
// Before the render is triggered, so that the pane the focus moves into can be
|
||||
// told where to put its selection as it renders.
|
||||
gui.followFocusIntoWorkablePane(opts)
|
||||
gui.keepDiffSelectionAcrossACommitRewrite(opts)
|
||||
|
||||
gui.moveMainContextPairToTop(opts.Pair)
|
||||
|
||||
gui.handOverMainSection(opts.Pair, panes)
|
||||
|
||||
if opts.Main != nil {
|
||||
gui.RefreshMainView(opts.Main, opts.Pair.Main)
|
||||
} else {
|
||||
gui.clearMainView(opts.Pair.Main)
|
||||
}
|
||||
|
||||
if opts.Secondary != nil {
|
||||
@@ -134,7 +152,180 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
gui.splitMainPanel(opts.Secondary != nil)
|
||||
gui.setMainPanes(panes)
|
||||
}
|
||||
|
||||
// handOverMainSection carries the content of the main section from the pane that has
|
||||
// been showing it on its own to the pane about to, when a render moves the section's
|
||||
// content from one to the other — a file's changes going from unstaged to staged, say.
|
||||
//
|
||||
// The section is one region of the screen to the user, so a change of which pane holds
|
||||
// it has to look like that region re-rendering rather than blanking and filling in
|
||||
// again: the incoming pane shows what the outgoing one was showing, where it was
|
||||
// showing it, until its own render has read enough to be swapped in. It renders from
|
||||
// the top when it does, the content it took over not being its own (see
|
||||
// clearMainView).
|
||||
func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) {
|
||||
// The lower pane is always the same view, being the only one a render can leave
|
||||
// holding the section on its own; the upper one is whichever view of the main
|
||||
// window this render is for, which moveMainContextPairToTop has just given a copy
|
||||
// of what that window was showing.
|
||||
upper, lower := pair.Main.GetView(), gui.Views.Secondary
|
||||
|
||||
var from, to *gocui.View
|
||||
switch {
|
||||
case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly:
|
||||
from, to = upper, lower
|
||||
case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly:
|
||||
from, to = lower, upper
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
gui.g.CopyContent(from, to)
|
||||
}
|
||||
|
||||
// mainPanesFor says which panes the given render occupies: the one it has content for,
|
||||
// or both when it has content for both.
|
||||
func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
|
||||
switch {
|
||||
case opts.Secondary == nil:
|
||||
return types.MainPaneOnly
|
||||
case opts.Main == nil:
|
||||
return types.SecondaryPaneOnly
|
||||
default:
|
||||
return types.BothMainPanes
|
||||
}
|
||||
}
|
||||
|
||||
// followFocusIntoWorkablePane moves the focus out of a main pane that the render about
|
||||
// to happen leaves nothing to work on, and into the one it does.
|
||||
//
|
||||
// Each side of a file's diff has a pane of its own, and a pane holds something only
|
||||
// while its side of the file does. So anything that empties the side the focus is on
|
||||
// leaves that pane with nothing: staging the last unstaged change, committing what was
|
||||
// staged, or either of those happening outside lazygit and arriving with a refresh.
|
||||
// Usually the pane goes away with its content; configured to always split the diff it
|
||||
// stays, empty. Either way the focus has nothing left to act on where it is.
|
||||
//
|
||||
// The pane moved into gets its selection once the render has finished and there is
|
||||
// something to put one on, and shows none until then, so that the selection it was
|
||||
// left with the last time it was used doesn't appear for a frame. A pane that has
|
||||
// already been told where to put its selection — by the action that caused all this —
|
||||
// keeps what it was told.
|
||||
func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) {
|
||||
// The focused main view's two panes only: the staging and patch-building views
|
||||
// arrange theirs for themselves, and the merge-conflicts view has just the one.
|
||||
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
|
||||
return
|
||||
}
|
||||
|
||||
current := gui.State.ContextMgr.CurrentStatic().GetKey()
|
||||
if current != opts.Pair.Main.GetKey() && current != opts.Pair.Secondary.GetKey() {
|
||||
return
|
||||
}
|
||||
pane := onlyWorkablePane(opts)
|
||||
if pane == nil || pane.GetKey() == current {
|
||||
return
|
||||
}
|
||||
|
||||
target := gui.mainContextForView(pane.GetView())
|
||||
target.SetHasSelectableContent(false)
|
||||
gui.State.ContextMgr.UpdateSelectionHighlights()
|
||||
if manager := gui.getManager(target.GetView()); !manager.HasRestoreForNextTask() {
|
||||
manager.SetRestoreForNextTask(&tasks.RenderRestore{
|
||||
// The whole render is read before it is shown: where the selection goes
|
||||
// is decided from what is there, and a change line further down would
|
||||
// otherwise be missed.
|
||||
FirstPaintReady: func() bool { return false },
|
||||
Apply: func(swapIn func()) {
|
||||
swapIn()
|
||||
gui.helpers.DiffLine.EstablishSelection(target, -1)
|
||||
},
|
||||
})
|
||||
}
|
||||
gui.State.ContextMgr.Push(target, types.OnFocusOpts{})
|
||||
}
|
||||
|
||||
// keepDiffSelectionAcrossACommitRewrite arranges for a selection in the focused main
|
||||
// view to come back on the same change of the diff when the render about to happen is
|
||||
// of a different diff from the one on screen. This happens when a commit is rewritten
|
||||
// under the user, by moving a patch out of it, discarding lines from it, or undoing
|
||||
// either. The selection is then left at a position in a rendering that no longer exists.
|
||||
//
|
||||
// What is remembered is which change of the diff the selection was on rather than which
|
||||
// line of which file, a rewrite being precisely a change to those lines: the change that
|
||||
// takes its place is where the work carries on.
|
||||
//
|
||||
// It is asked of every render, and does nothing unless all three of these hold: there
|
||||
// is a selection to keep; nothing more precise is already waiting to be put back (the
|
||||
// position preserves and the post-action reveals know better where their selection
|
||||
// belongs); and the diff really is another one. A plain refresh re-renders the same
|
||||
// diff, where the selection, possibly a range the user is in the middle of making, is
|
||||
// still exactly right.
|
||||
func (gui *Gui) keepDiffSelectionAcrossACommitRewrite(opts types.RefreshMainOpts) {
|
||||
// The focused main view's two panes only: no other pair has a diff selection.
|
||||
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
|
||||
return
|
||||
}
|
||||
|
||||
current := gui.State.ContextMgr.CurrentStatic().GetKey()
|
||||
for _, pane := range []struct {
|
||||
context types.Context
|
||||
update *types.ViewUpdateOpts
|
||||
}{
|
||||
{opts.Pair.Main, opts.Main},
|
||||
{opts.Pair.Secondary, opts.Secondary},
|
||||
} {
|
||||
if pane.update == nil || pane.context.GetKey() != current {
|
||||
continue
|
||||
}
|
||||
mainContext := gui.mainContextForView(pane.context.GetView())
|
||||
if mainContext == nil || !mainContext.GetView().Highlight {
|
||||
continue
|
||||
}
|
||||
manager := gui.getViewBufferManagerForView(mainContext.GetView())
|
||||
if manager == nil || manager.HasRestoreForNextTask() {
|
||||
continue
|
||||
}
|
||||
key, ok := diffTaskCommandKey(pane.update.Task)
|
||||
if !ok || key == manager.GetTaskKey() {
|
||||
continue
|
||||
}
|
||||
|
||||
first, _ := mainContext.GetView().SelectedLineRange()
|
||||
gui.helpers.DiffLine.RevealSelectionAfterAction(mainContext, mainContext, first, 0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// diffTaskCommandKey returns the key the given render will be remembered under. Two
|
||||
// renders of the same diff have the same key, so comparing keys says whether a render
|
||||
// is of the diff already on screen. ok is false for a render that is a message rather
|
||||
// than a diff.
|
||||
func diffTaskCommandKey(task types.UpdateTask) (string, bool) {
|
||||
switch task := task.(type) {
|
||||
case *types.RunCommandTask:
|
||||
return strings.Join(task.Cmd.Args, " "), true
|
||||
case *types.RunDiffRendererTask:
|
||||
return strings.Join(task.Cmd.Args, " "), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// onlyWorkablePane returns the main pane a render leaves as the only one worth having
|
||||
// the focus in, or nil when that is true of both of them or of neither. Being shown is
|
||||
// not the same as being worth working in: a pane the layout keeps around for the sake
|
||||
// of always splitting the diff shows an empty side of the file.
|
||||
func onlyWorkablePane(opts types.RefreshMainOpts) types.Context {
|
||||
main := opts.Main != nil && !opts.Main.NothingToActOn
|
||||
secondary := opts.Secondary != nil && !opts.Secondary.NothingToActOn
|
||||
if main == secondary {
|
||||
return nil
|
||||
}
|
||||
if main {
|
||||
return opts.Pair.Main
|
||||
}
|
||||
return opts.Pair.Secondary
|
||||
}
|
||||
|
||||
// clampDiffSelectionToContent brings the focused main view's selection back onto the
|
||||
@@ -162,18 +353,38 @@ func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
|
||||
}
|
||||
|
||||
// clearMainView empties a pane that is being given nothing to show, selection and all.
|
||||
//
|
||||
// An emptied pane is showing nothing, so it also goes back to the top and stops
|
||||
// claiming the render it was showing: whatever it is given next is content the user
|
||||
// hasn't seen there, and is shown from the top like any other.
|
||||
//
|
||||
// A position waiting to be put back goes too: this pane is getting no render for it
|
||||
// to ride, and whoever is waiting for the view to be back where it belongs has to
|
||||
// hear that it never will be.
|
||||
func (gui *Gui) clearMainView(mainContext types.Context) {
|
||||
mainContext.GetView().Clear()
|
||||
view := mainContext.GetView()
|
||||
view.Clear()
|
||||
view.SetOrigin(0, 0)
|
||||
mainContext.SetHasSelectableContent(false)
|
||||
if pane := gui.mainContextForView(view); pane != nil {
|
||||
pane.SetContentIsDiff(false)
|
||||
}
|
||||
gui.State.ContextMgr.UpdateSelectionHighlights()
|
||||
if manager := gui.getViewBufferManagerForView(view); manager != nil {
|
||||
manager.ForgetRenderedContent()
|
||||
manager.DropRestoreForNextTask()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// updateDiffPaneDecorations re-derives what is drawn over a main pane's content, rather
|
||||
// than being part of it: whether a selection is shown, and which lines are marked as
|
||||
// being in the custom patch.
|
||||
//
|
||||
// A pane holds something for a selection to sit on only beneath a panel whose main
|
||||
// view is a diff, only while the pane is showing that diff rather than a message like
|
||||
// "No changed files", and only while the diff holds something to select — never over
|
||||
// one 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
|
||||
@@ -181,7 +392,7 @@ func (gui *Gui) clearMainView(mainContext types.Context) {
|
||||
// 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) {
|
||||
func (gui *Gui) updateDiffPaneDecorations(view *gocui.View, contentIsComplete bool) {
|
||||
mainContext := gui.mainContextForView(view)
|
||||
if mainContext == nil {
|
||||
return
|
||||
@@ -197,6 +408,12 @@ func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplet
|
||||
} else {
|
||||
gui.readOnUntilTheDiffPaneCanTell(view)
|
||||
}
|
||||
|
||||
// The marks are over the diff in the upper pane; the lower one shows the patch
|
||||
// they are marks of.
|
||||
if view == gui.Views.Main {
|
||||
gui.helpers.DiffLine.RefreshInclusionGutter()
|
||||
}
|
||||
}
|
||||
|
||||
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
|
||||
@@ -251,6 +468,14 @@ func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
|
||||
func (gui *Gui) diffPaneHasSomethingToSelect(
|
||||
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
|
||||
) (bool, bool) {
|
||||
if !mainContext.ContentIsDiff() {
|
||||
// The pane is holding something other than the panel's diff: a message, or the
|
||||
// hint shown for a merge conflict that has to be resolved by picking a side,
|
||||
// which explains itself with a diff of what the sides did. Whatever is in there
|
||||
// is not ours to act on, so this needs no content to answer either.
|
||||
return false, true
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -289,8 +514,24 @@ func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
|
||||
gui.State.SplitMainPanel = splitMainPanel
|
||||
func (gui *Gui) setMainPanes(panes types.MainPanes) {
|
||||
gui.State.MainPanes = panes
|
||||
|
||||
// The label for the key that focuses the main view belongs on the pane that key
|
||||
// focuses, which is the secondary one while it is the only one shown.
|
||||
if panes == types.SecondaryPaneOnly {
|
||||
gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary)
|
||||
} else {
|
||||
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
|
||||
}
|
||||
}
|
||||
|
||||
// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and
|
||||
// takes it off the other one, so that only the pane the key focuses wears it.
|
||||
func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) {
|
||||
gui.Views.Main.TitlePrefix = ""
|
||||
gui.Views.Secondary.TitlePrefix = ""
|
||||
view.TitlePrefix = gui.focusMainViewJumpLabel
|
||||
}
|
||||
|
||||
// reApplySearch runs a search the view holds again over the content a render has just
|
||||
|
||||
@@ -51,6 +51,11 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
}
|
||||
|
||||
// The key the render is remembered under says which diff it is of, so that a
|
||||
// re-render of the same diff can be told from a render of another one. Take
|
||||
// it before anything else can touch the command's arguments.
|
||||
cmdStr := strings.Join(cmd.Args, " ")
|
||||
|
||||
// Mark the view as loading synchronously now, before the layout pass: the
|
||||
// actual task is created in afterLayout (below), which runs after layout, so
|
||||
// without this the next layout pass would clamp the scroll position to the
|
||||
@@ -81,8 +86,6 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
gui.c.ErrorToast(err.Error())
|
||||
}
|
||||
|
||||
cmdStr := strings.Join(cmd.Args, " ")
|
||||
|
||||
// This communicates to diff renderers that we're in a very simple
|
||||
// terminal that they should not expect to have much capabilities.
|
||||
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
|
||||
|
||||
@@ -50,7 +50,11 @@ func (self *OptionsMapMgr) renderContextOptionsMap() {
|
||||
})...)
|
||||
|
||||
bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool {
|
||||
return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled()
|
||||
// A binding that describes itself as nothing has nothing to do where we are —
|
||||
// that is how a command which only applies to some of a view's contents says so
|
||||
// — and an empty entry in the options bar would say nothing about it.
|
||||
return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled() &&
|
||||
binding.GetShortDescription() != ""
|
||||
})
|
||||
|
||||
optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo {
|
||||
|
||||
@@ -227,9 +227,10 @@ M file1
|
||||
}
|
||||
patchBuilder := patch.NewPatchBuilder(
|
||||
utils.NewDummyLog(),
|
||||
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
patchBuilder.Start("from", "to", false, false)
|
||||
result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{})
|
||||
|
||||
@@ -87,11 +87,14 @@ func (gui *Gui) newStringTask(view *gocui.View, str string) error {
|
||||
|
||||
func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
||||
manager := gui.getManager(view)
|
||||
// Whatever the view was going to be put back to belonged to a re-render of its
|
||||
// content; this is a message instead, so there is nothing to put back.
|
||||
manager.DropRestoreForNextTask()
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -105,12 +108,15 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
||||
|
||||
func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX int, originY int) error {
|
||||
manager := gui.getManager(view)
|
||||
// Whatever the view was going to be put back to belonged to a re-render of its
|
||||
// content; this is a message instead, so there is nothing to put back.
|
||||
manager.DropRestoreForNextTask()
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -124,12 +130,15 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
|
||||
|
||||
func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) error {
|
||||
manager := gui.getManager(view)
|
||||
// Whatever the view was going to be put back to belonged to a re-render of its
|
||||
// content; this is a message instead, so there is nothing to put back.
|
||||
manager.DropRestoreForNextTask()
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -168,7 +177,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
// 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)
|
||||
gui.updateDiffPaneDecorations(view, false)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -187,7 +196,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
view.SetOrigin(0, newOriginY)
|
||||
}
|
||||
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.clampDiffSelectionToContent(view)
|
||||
gui.reApplySearch(view)
|
||||
},
|
||||
@@ -201,7 +210,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
// 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)
|
||||
gui.updateDiffPaneDecorations(view, false)
|
||||
},
|
||||
func() gocui.Task {
|
||||
// A background task: rendering content into a view is display
|
||||
|
||||
@@ -72,6 +72,10 @@ type IGuiCommon interface {
|
||||
// return the view buffer manager for the given view, or nil if it doesn't have one
|
||||
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
|
||||
|
||||
// return the view buffer manager for the given view, making one if the view has
|
||||
// never rendered anything, for saying something about a render still to come
|
||||
GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
|
||||
|
||||
// read enough lines into the given view's buffer to fill it at its current
|
||||
// scroll position, plus some read-ahead for smooth scrolling
|
||||
ReadLinesToFillView(view *gocui.View)
|
||||
@@ -461,8 +465,8 @@ type IRepoStateAccessor interface {
|
||||
SetScreenMode(ScreenMode)
|
||||
InSearchPrompt() bool
|
||||
GetSearchState() *SearchState
|
||||
SetSplitMainPanel(bool)
|
||||
GetSplitMainPanel() bool
|
||||
SetMainPanes(MainPanes)
|
||||
GetMainPanes() MainPanes
|
||||
GetMergeOrRebaseStartedInLazygit() bool
|
||||
SetMergeOrRebaseStartedInLazygit(bool)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ type IBaseContext interface {
|
||||
// Adding on to the above, this is so that a list-specific handler can register
|
||||
// a hook for doing additional click handling
|
||||
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
|
||||
// Likewise for the focused main view, which acts on the diff of whichever panel
|
||||
// is beneath it and so has to reach that panel's controller. nil for a panel
|
||||
// that shows no diff.
|
||||
AddFocusedMainViewDiffSource(FocusedMainViewDiffSource)
|
||||
|
||||
AddOnRenderToMainFn(func())
|
||||
AddOnFocusFn(func(OnFocusOpts))
|
||||
@@ -220,6 +224,54 @@ type DiffPaneContext interface {
|
||||
DiffSelectState() *DiffSelectState
|
||||
}
|
||||
|
||||
// FocusedMainViewDiffSource is how a side panel hands out the diff behind what it
|
||||
// renders into the focused main view: the diff of the given files as git writes it,
|
||||
// with no colour and no diff renderer in the way. What the main view shows is that
|
||||
// same diff after a renderer has had it, which may have restructured, reordered or
|
||||
// dropped parts of it — so anything that needs the diff itself, rather than a picture
|
||||
// of it, asks the panel that produced it.
|
||||
//
|
||||
// paths are repo-relative, and are asked for rather than assumed so that a few lines
|
||||
// of a commit's diff can be had without fetching the whole thing. pane says which of
|
||||
// the two main panes is asking, since a panel can show a different diff in each — the
|
||||
// files panel shows the unstaged changes in one and the staged ones in the other.
|
||||
type FocusedMainViewDiffSource interface {
|
||||
PlainDiff(pane DiffPaneContext, paths []string) string
|
||||
}
|
||||
|
||||
// FocusedMainViewActions describes what a side panel does when the user acts on a
|
||||
// selection of diff lines in the focused main view. The main view owns the selection
|
||||
// and the keys; what acting on it means is the panel's business, e.g. the working
|
||||
// tree panel stages and unstages.
|
||||
//
|
||||
// It extends the diff source rather than standing beside it, because acting on a
|
||||
// selection needs the diff behind the rendering just as reading it does; a panel that
|
||||
// implements only the source offers a diff to read and copy but nothing to do to it.
|
||||
type FocusedMainViewActions interface {
|
||||
FocusedMainViewDiffSource
|
||||
|
||||
// PrimaryAction acts on the diff lines in the inclusive view-line range, which is
|
||||
// the current selection in the given pane: a single line, a range, or a hunk. The
|
||||
// panel re-renders the diff itself, being the one that knows what it did to it.
|
||||
PrimaryAction(pane DiffPaneContext, firstLineIdx int, lastLineIdx int) error
|
||||
|
||||
// DiscardSelection takes the selected diff lines back out of whatever they are part
|
||||
// of: the working tree for the files panel, the commit itself for the panels showing
|
||||
// a commit's diff.
|
||||
DiscardSelection(pane DiffPaneContext, firstLineIdx int, lastLineIdx int) error
|
||||
|
||||
// DiscardSelectionDisabledReason says why the selection can't be discarded where it
|
||||
// is, and nil when it can. Taking lines out of a commit means rewriting it, which
|
||||
// isn't always something we may do; the working tree has no such condition.
|
||||
DiscardSelectionDisabledReason(pane DiffPaneContext) *DisabledReason
|
||||
|
||||
// PatchInclusion says which lines of the diff this panel shows are in the custom
|
||||
// patch being built from it. The marks over those lines are drawn from this. nil
|
||||
// where nothing about this diff is being built into a patch, which is always so
|
||||
// for a diff that can't be.
|
||||
PatchInclusion() func(info DiffLineInfo) bool
|
||||
}
|
||||
|
||||
type IListContext interface {
|
||||
Context
|
||||
|
||||
@@ -315,6 +367,11 @@ type HasKeybindings interface {
|
||||
// HandleFocus has already been called (so the main view is up to date). Should return nil if it
|
||||
// decides not to do anything with the click.
|
||||
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
|
||||
|
||||
// Implement this in a side-panel controller to hand out the diff behind what your
|
||||
// panel renders into the focused main view, for the commands that act on a
|
||||
// selection in it. nil for a controller whose panel shows no diff.
|
||||
GetFocusedMainViewDiffSource() FocusedMainViewDiffSource
|
||||
}
|
||||
|
||||
type IController interface {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package types
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
|
||||
// DiffLineType classifies a row of a rendered diff.
|
||||
type DiffLineType int
|
||||
|
||||
@@ -40,3 +42,21 @@ type DiffLineInfo struct {
|
||||
func (self DiffLineInfo) IsChange() bool {
|
||||
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
|
||||
}
|
||||
|
||||
// PatchLineIdentity says which change line of the file the row is, in the terms a patch
|
||||
// of that file is built and read in: an addition by where it sits in the new version of
|
||||
// the file, a deletion by where it sat in the old one. Only meaningful for a change row.
|
||||
func (self DiffLineInfo) PatchLineIdentity() patch.LineIdentity {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return patch.LineIdentity{LineNumber: self.OldLine, IsDeletion: true}
|
||||
}
|
||||
return patch.LineIdentity{LineNumber: self.NewLine}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ type Binding struct {
|
||||
|
||||
// to be displayed if the keybinding is highlighted from within a menu
|
||||
Tooltip string
|
||||
// TooltipFunc is used instead of Tooltip if non-nil, for a command whose tooltip
|
||||
// depends on context, as DescriptionFunc is for its description — and with the
|
||||
// same two conditions: it must not be an expensive call, and a generic Tooltip
|
||||
// must still be given, since that is the one the cheatsheet prints.
|
||||
TooltipFunc func() string
|
||||
|
||||
// Function to decide whether the command is enabled, and why. If this
|
||||
// returns an empty string, it is; if it returns a non-empty string, it is
|
||||
@@ -59,6 +64,13 @@ func (b *Binding) GetDescription() string {
|
||||
return b.Description
|
||||
}
|
||||
|
||||
func (b *Binding) GetTooltip() string {
|
||||
if b.TooltipFunc != nil {
|
||||
return b.TooltipFunc()
|
||||
}
|
||||
return b.Tooltip
|
||||
}
|
||||
|
||||
func (b *Binding) GetShortDescription() string {
|
||||
if b.ShortDescriptionFunc != nil {
|
||||
return b.ShortDescriptionFunc()
|
||||
|
||||
@@ -2,6 +2,8 @@ package types
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
)
|
||||
|
||||
type MainContextPair struct {
|
||||
@@ -13,6 +15,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
|
||||
@@ -25,6 +40,11 @@ type ViewUpdateOpts struct {
|
||||
SubTitle string
|
||||
|
||||
Task UpdateTask
|
||||
|
||||
// NothingToActOn marks a pane that is being shown only because the layout is
|
||||
// configured to always split the diff: its side of the file holds nothing, so it
|
||||
// is not a pane to leave the focus in.
|
||||
NothingToActOn bool
|
||||
}
|
||||
|
||||
type RefreshMainOpts struct {
|
||||
@@ -72,6 +92,9 @@ func NewRenderStringWithScrollTask(str string, originX int, originY int) *Render
|
||||
type RunCommandTask struct {
|
||||
Cmd *exec.Cmd
|
||||
Prefix string
|
||||
|
||||
// contentIsDiff marks output that is a panel's own diff; see ContentIsDiff.
|
||||
contentIsDiff bool
|
||||
}
|
||||
|
||||
func (t *RunCommandTask) IsUpdateTask() {}
|
||||
@@ -87,6 +110,9 @@ func NewRunCommandTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunCommandTask {
|
||||
type RunDiffRendererTask struct {
|
||||
Cmd *exec.Cmd
|
||||
Prefix string
|
||||
|
||||
// contentIsDiff marks output that is a panel's own diff; see ContentIsDiff.
|
||||
contentIsDiff bool
|
||||
}
|
||||
|
||||
func (t *RunDiffRendererTask) IsUpdateTask() {}
|
||||
@@ -98,3 +124,39 @@ func NewRunDiffRendererTask(cmd *exec.Cmd) *RunDiffRendererTask {
|
||||
func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRendererTask {
|
||||
return &RunDiffRendererTask{Cmd: cmd, Prefix: prefix}
|
||||
}
|
||||
|
||||
// NewMainViewDiffTask returns the task for rendering a diff into a main view. A diff
|
||||
// normally goes through the diff renderer, however the render arranges to feed it. A
|
||||
// diff we are producing with git itself, because the renderer's version of it couldn't
|
||||
// be acted on, has to keep the renderer out, so it runs as a plain command instead.
|
||||
//
|
||||
// The task it returns is the one that says its output is a diff, so a pane rendering
|
||||
// it can be pointed at (see ContentIsDiff).
|
||||
func NewMainViewDiffTask(cmd *exec.Cmd, mode git_commands.DiffMode) UpdateTask {
|
||||
return NewMainViewDiffTaskWithPrefix(cmd, "", mode)
|
||||
}
|
||||
|
||||
func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_commands.DiffMode) UpdateTask {
|
||||
if mode == git_commands.DiffModeRaw {
|
||||
task := NewRunCommandTaskWithPrefix(cmd, prefix)
|
||||
task.contentIsDiff = true
|
||||
return task
|
||||
}
|
||||
task := NewRunDiffRendererTaskWithPrefix(cmd, prefix)
|
||||
task.contentIsDiff = true
|
||||
return task
|
||||
}
|
||||
|
||||
// ContentIsDiff reports whether the given render fills a main pane with the diff a
|
||||
// panel offers there, as opposed to a message, a commit log, or a diff that is part of
|
||||
// an explanation. A selection means the lines of the panel's diff, so it is only over
|
||||
// such a render that there is anything to point at.
|
||||
func ContentIsDiff(task UpdateTask) bool {
|
||||
switch task := task.(type) {
|
||||
case *RunCommandTask:
|
||||
return task.contentIsDiff
|
||||
case *RunDiffRendererTask:
|
||||
return task.contentIsDiff
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+10
-3
@@ -177,6 +177,13 @@ func (gui *Gui) createAllViews() error {
|
||||
gui.Views.Main.SelectedLineColorWidth = 2
|
||||
gui.Views.Secondary.SelectedLineColorWidth = 2
|
||||
|
||||
// A tick, for the lines of a commit's diff that are in the custom patch being
|
||||
// built. A plus would collide with a diff's own plus column.
|
||||
gui.Views.Main.InclusionGutterMarker = "✓"
|
||||
gui.Views.Main.InclusionGutterMarkerColor = gocui.ColorGreen
|
||||
gui.Views.Secondary.InclusionGutterMarker = "✓"
|
||||
gui.Views.Secondary.InclusionGutterMarkerColor = gocui.ColorGreen
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -283,11 +290,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
|
||||
|
||||
@@ -472,6 +472,7 @@ type TranslationSet struct {
|
||||
CheckoutCommitFileTooltip string
|
||||
CannotCheckoutWithModifiedFilesErr string
|
||||
CanOnlyDiscardFromLocalCommits string
|
||||
CannotDiscardFromCustomPatchView string
|
||||
CannotDiscardFromMultipleCommits string
|
||||
Remove string
|
||||
DiscardOldFileChangeTooltip string
|
||||
@@ -736,6 +737,8 @@ type TranslationSet struct {
|
||||
CopyPathToClipboard string
|
||||
CommitPrefixPatternError string
|
||||
CopySelectedTextToClipboard string
|
||||
SelectedDiffLinesCopiedToast string
|
||||
SelectionNotFoundInDiffToast string
|
||||
NoFilesStagedTitle string
|
||||
NoFilesStagedPrompt string
|
||||
BranchNotFoundTitle string
|
||||
@@ -1663,6 +1666,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.",
|
||||
CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.",
|
||||
CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits",
|
||||
CannotDiscardFromCustomPatchView: "Lines shown here are the custom patch's; press space to take them back out of it",
|
||||
CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits",
|
||||
Remove: "Remove",
|
||||
DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.",
|
||||
@@ -1925,6 +1929,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)
|
||||
@@ -393,6 +404,48 @@ func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// MarkedLines asserts which lines of the view are marked as being in the custom patch
|
||||
// being built. The marks are drawn over the content rather than being part of it, so
|
||||
// they are read from the view rather than matched against what Content returns.
|
||||
func (self *ViewDriver) MarkedLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
self.validateMatchersPassed(matchers)
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
markedLines := self.getView().MarkedLines()
|
||||
|
||||
markedContent := strings.Join(markedLines, "\n")
|
||||
expectedContent := expectedContentFromMatchers(matchers)
|
||||
|
||||
if len(markedLines) != len(matchers) {
|
||||
return false, fmt.Sprintf("%s: Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, expectedContent, markedContent)
|
||||
}
|
||||
|
||||
for i, line := range markedLines {
|
||||
ok, message := matchers[i].test(line)
|
||||
if !ok {
|
||||
return false, fmt.Sprintf("%s: Error: %s. Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, message, expectedContent, markedContent)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// NoMarkedLines asserts that no line of the view is marked as being in the custom
|
||||
// patch, which is also what a view showing no marks at all reports.
|
||||
func (self *ViewDriver) NoMarkedLines() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
markedLines := self.getView().MarkedLines()
|
||||
return len(markedLines) == 0, fmt.Sprintf(
|
||||
"%s: Expected no line to be marked as being in the custom patch, but these were:\n-----\n%s\n-----",
|
||||
self.context, strings.Join(markedLines, "\n"))
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// SelectionIsActive asserts that the view draws its selection as the one the user
|
||||
// is working in. These three assertions read the highlight flags rather than the
|
||||
// selected lines, which say nothing about whether the selection is drawn at all.
|
||||
|
||||
@@ -54,6 +54,6 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Contains("M file").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
|
||||
t.Views().Secondary().Content(Contains("-a1\n+a2\n").DoesNotContain("-no eol"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Focus().
|
||||
SelectedLine(Contains("file1"))
|
||||
|
||||
t.Views().Main().Content(Contains("+second line"))
|
||||
// The patch was applied to the index, so the file's changes are all staged.
|
||||
t.Views().Secondary().Content(Contains("+second line"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Equals(" R dir/nested/file3 → file3"),
|
||||
)
|
||||
|
||||
t.Views().Main().ContainsLines(
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Equals("diff --git a/file1 b/dir/file1"),
|
||||
Equals("similarity index 100%"),
|
||||
Equals("rename from file1"),
|
||||
@@ -51,7 +51,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
SelectNextItem().
|
||||
SelectedLine(Equals(" ▼ dir"))
|
||||
|
||||
t.Views().Main().
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Equals("diff --git a/file1 b/dir/file1"),
|
||||
Equals("similarity index 100%"),
|
||||
@@ -75,7 +75,7 @@ var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Equals(" R file1 → file1"),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Equals("diff --git a/file1 b/dir/file1"),
|
||||
Equals("similarity index 100%"),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var PaneShownAgainStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A pane that was emptied while it wasn't shown starts at the top when it comes back, rather than where it was left",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 120,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 40)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%02d", i+1)
|
||||
}
|
||||
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
|
||||
shell.CreateFileAndAdd("file2", "one\n")
|
||||
shell.Commit("one")
|
||||
|
||||
// More staged changes in file1 than fit in the pane they are shown in, so that
|
||||
// there is a position in it to lose, plus an unstaged change to give the file a
|
||||
// second pane.
|
||||
for i := range lines {
|
||||
lines[i] = strings.ToUpper(lines[i])
|
||||
}
|
||||
shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
|
||||
shell.UpdateFile("file1", strings.Join(lines, "\n")+"unstaged\n")
|
||||
|
||||
shell.UpdateFile("file2", "two\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("file1"))
|
||||
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Title(Equals("Staged changes")).
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
OriginYAtLeast(1)
|
||||
|
||||
// A file with nothing staged leaves that pane with nothing to show, so it goes
|
||||
// away and is emptied.
|
||||
t.Views().Files().NavigateToLine(Contains("file2"))
|
||||
t.Views().Secondary().IsInvisible()
|
||||
|
||||
t.Views().Files().NavigateToLine(Contains("file1"))
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Content(Contains("+LINE40")).
|
||||
OriginY(0)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
// changedLines is a file's worth of numbered lines, prefixed so that each file's diff
|
||||
// can be told from the other's on screen.
|
||||
func changedLines(prefix string) string {
|
||||
lines := make([]string, 40)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("%s%02d", prefix, i+1)
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
var PaneTakingOverStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A pane taking the main section over shows its diff from the top, rather than at the offset it was left at",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 120,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", changedLines("one"))
|
||||
shell.CreateFileAndAdd("file2", changedLines("two"))
|
||||
shell.Commit("one")
|
||||
|
||||
// One file's changes are unstaged and the other's are staged, so each is shown
|
||||
// in a pane of its own — and selecting one after the other hands the section
|
||||
// from one pane to the other. Both diffs are longer than the section, so either
|
||||
// pane can be scrolled.
|
||||
shell.UpdateFile("file1", changedLines("ONE"))
|
||||
shell.UpdateFileAndAdd("file2", changedLines("TWO"))
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("file1"))
|
||||
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsVisible().
|
||||
Title(Equals("Unstaged changes")).
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
OriginYAtLeast(1)
|
||||
|
||||
t.Views().Files().NavigateToLine(Contains("file2"))
|
||||
|
||||
t.Views().Main().IsInvisible()
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Title(Equals("Staged changes")).
|
||||
Content(Contains("+TWO40")).
|
||||
OriginY(0).
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
OriginYAtLeast(1)
|
||||
|
||||
// Back to the pane that was left scrolled: what it is given is a diff the user
|
||||
// hasn't seen there, so it starts at the top like any other.
|
||||
t.Views().Files().NavigateToLine(Contains("file1"))
|
||||
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsVisible().
|
||||
Title(Equals("Unstaged changes")).
|
||||
Content(Contains("+ONE40")).
|
||||
OriginY(0)
|
||||
|
||||
t.Views().Files().NavigateToLine(Contains("file2"))
|
||||
|
||||
t.Views().Main().IsInvisible()
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Title(Equals("Staged changes")).
|
||||
Content(Contains("+TWO40")).
|
||||
OriginY(0)
|
||||
},
|
||||
})
|
||||
@@ -34,7 +34,7 @@ var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
).
|
||||
Press(keys.Universal.FocusMainView).
|
||||
Tap(func() {
|
||||
t.Views().Main().
|
||||
t.Views().Secondary().
|
||||
Press(keys.Universal.IncreaseRenameSimilarityThreshold)
|
||||
t.ExpectToast(Equals("Changed rename similarity threshold to 50%"))
|
||||
}).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var StagedChangesInLowerPane = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A file's staged changes are shown in the lower pane whether or not it also has unstaged ones",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("both", "one\n")
|
||||
shell.CreateFileAndAdd("indexOnly", "one\n")
|
||||
shell.CreateFileAndAdd("worktreeOnly", "one\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("both", "one\nstaged\n")
|
||||
shell.UpdateFile("both", "one\nstaged\nunstaged\n")
|
||||
// More staged lines than fit in the pane, so that it can be scrolled.
|
||||
staged := make([]string, 40)
|
||||
for i := range staged {
|
||||
staged[i] = fmt.Sprintf("staged%02d", i+1)
|
||||
}
|
||||
shell.UpdateFileAndAdd("indexOnly", "one\n"+strings.Join(staged, "\n")+"\n")
|
||||
shell.UpdateFile("worktreeOnly", "one\nunstaged\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("both"))
|
||||
|
||||
// With changes on both sides, each side has its own pane.
|
||||
t.Views().Main().
|
||||
Title(Equals("Unstaged changes")).
|
||||
Content(Contains("+unstaged"))
|
||||
t.Views().Secondary().
|
||||
Title(Equals("Staged changes")).
|
||||
Content(Contains("+staged"))
|
||||
|
||||
// With nothing unstaged, the staged side keeps its pane, which then has the
|
||||
// whole space to itself.
|
||||
t.Views().Files().NavigateToLine(Contains("indexOnly"))
|
||||
|
||||
t.Views().Main().IsInvisible()
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Title(Equals("Staged changes")).
|
||||
Content(Contains("+staged01")).
|
||||
// The key that focuses the diff wears its label, wherever the diff is.
|
||||
TitlePrefix(Equals("[0]")).
|
||||
OriginY(0)
|
||||
|
||||
// And the keys for scrolling the diff scroll the pane it is in.
|
||||
t.GlobalPress(keys.Universal.ScrollDownMain)
|
||||
t.Views().Secondary().OriginYAtLeast(1)
|
||||
t.GlobalPress(keys.Universal.ScrollUpMain)
|
||||
t.Views().Secondary().OriginY(0)
|
||||
|
||||
// And focusing the diff focuses the pane it is in.
|
||||
t.Views().Files().Press(keys.Universal.FocusMainView)
|
||||
t.Views().Secondary().IsFocused()
|
||||
t.Views().Secondary().PressEscape()
|
||||
|
||||
// With nothing staged, only the upper pane is shown.
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("worktreeOnly"))
|
||||
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsVisible().
|
||||
Title(Equals("Unstaged changes")).
|
||||
Content(Contains("+unstaged")).
|
||||
TitlePrefix(Equals("[0]"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var AdvanceAfterStagingShiftsLineNumbers = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Staging a hunk that adds a line still leaves the selection on the next hunk, whose line numbers it moved",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "1\n2\n3\n4\n5\n6\n7\n8\n")
|
||||
shell.Commit("one")
|
||||
|
||||
// Three change blocks: a modification, an added line, and another modification
|
||||
// below it. Staging the middle one changes how many lines the file has, and so
|
||||
// where the last one sits.
|
||||
shell.UpdateFile("file1", "1\nX\n3\n4\nNEW\n5\n6\nY\n8\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-2"),
|
||||
Contains("+X"),
|
||||
).
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("+NEW"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("-7"),
|
||||
Contains("+Y"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of a commit's diff into a custom patch, and back out of it, from 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("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "ONE\ntwo\nTHREE\nfour\nfive\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
).
|
||||
// The line goes into the patch, which the pane beside the diff previews, and
|
||||
// the selection moves on to the next change rather than staying where a
|
||||
// second press would take it straight back out.
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("+ONE"),
|
||||
)
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains(" two"),
|
||||
)
|
||||
// The line that is in the patch is marked as such over the diff itself.
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
)
|
||||
|
||||
// The addition of the same modification goes in too, and the patch holds both.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
)
|
||||
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
Contains(" two"),
|
||||
)
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
)
|
||||
|
||||
// Pointing at a line that is in the patch takes it back out.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("+ONE")).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains(" two"),
|
||||
).
|
||||
Content(DoesNotContain("+ONE"))
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
)
|
||||
|
||||
// Taking the last line out ends the patch, so the pane previewing it goes away.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("-one")).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(DoesNotContain("Building patch"))
|
||||
t.Views().Main().NoMarkedLines()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromAReflogEntry = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of the diff of a reflog entry into a custom patch, which can then be applied",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().ReflogCommits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
)
|
||||
|
||||
// A reflog entry is never a commit we may rewrite, so the patch can be applied
|
||||
// but not moved out of the commit it came from.
|
||||
t.Common().SelectPatchOption(Contains("Apply patch in reverse"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("M").Contains("file1"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromAWholeCommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of two files into a custom patch from the whole diff of a commit, without entering its files first",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// The commit's whole diff spans both files, and hunk mode offers the first
|
||||
// changed block of the first of them.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// The selection moves past the block just taken in, which is file2's block.
|
||||
SelectedLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch spans both files.
|
||||
t.Views().Secondary().
|
||||
Content(Contains("file1")).
|
||||
Content(Contains("file2")).
|
||||
ContainsLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
Contains(" three"),
|
||||
).
|
||||
ContainsLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
Contains(" gamma"),
|
||||
)
|
||||
|
||||
// Applying it to the working tree puts both files' changes there, which is the
|
||||
// proof that the patch really holds what the preview says it does.
|
||||
t.Common().SelectPatchOption(Contains("Apply patch in reverse"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
ContainsLines(
|
||||
Contains("M").Contains("file1"),
|
||||
Contains("M").Contains("file2"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CommitFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Commit what you staged without leaving the focused main view, but not while looking at a commit's diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\nADDED\ntwo\nthree\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Committing acts on the working tree, so it is offered over the working tree's
|
||||
// diff and does nothing over a commit's.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Files.CommitChanges).
|
||||
// Nothing happened: the commit message panel would have taken the focus.
|
||||
IsFocused()
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+ADDED"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// Staging it left nothing unstaged, so the diff — and the focus with it — is in
|
||||
// the pane the staged side has.
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Press(keys.Files.CommitChanges)
|
||||
|
||||
t.ExpectPopup().CommitMessagePanel().
|
||||
Type("staged from the diff").
|
||||
Confirm()
|
||||
|
||||
// The commit left the staged side with nothing in it, so the pane it was made
|
||||
// from is gone and the focus is in the one that is still there.
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("No changed files"))
|
||||
|
||||
t.Views().Commits().Lines(
|
||||
Contains("staged from the diff"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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,163 @@
|
||||
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 announces the metadata protocol, so that
|
||||
// its output is taken at its word rather than replaced by git's own; and it
|
||||
// reads the +/- column, so it wants its input uncoloured.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{
|
||||
Command: `printf '\033]1717;1\007'; ` +
|
||||
`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`))
|
||||
|
||||
// The pane showing the custom patch is a diff too, of the patch's own lines.
|
||||
t.Views().Main().
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-two <<<"),
|
||||
Contains("+TWO <<<"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two <<<"),
|
||||
Contains("+TWO <<<"),
|
||||
).
|
||||
Press(keys.Universal.CopyToClipboard)
|
||||
|
||||
expectClipboard(t, Equals("-two\n+TWO\n"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CustomPatchGoesThroughTheDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The custom patch is shown by the configured diff renderer, being rendered as a diff of real files rather than assembled by us",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// A renderer that announces the metadata protocol — so that focusing the main
|
||||
// view keeps its output — and says who it is above the diff it passes through.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `printf '\033]1717;1\007RENDERED BY ME\n'; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("RENDERED BY ME")).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch is a diff like any other, so the renderer has had it too. git worked
|
||||
// out its context, and that is where the unchanged line either side comes from.
|
||||
t.Views().Secondary().
|
||||
Content(Contains("RENDERED BY ME")).
|
||||
ContainsLines(
|
||||
Contains(" one"),
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CustomPatchIgnoresLineEndingConversion = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The custom patch is shown as git states it on a machine that checks files out with CRLF",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
// The patch is materialized into trees of its own outside the repo, so what the repo
|
||||
// says about line endings never reaches the commands over them. These variables are
|
||||
// how a setting reaches a git command wherever it runs.
|
||||
ExtraEnvVars: map[string]string{
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "core.autocrlf",
|
||||
"GIT_CONFIG_VALUE_0": "true",
|
||||
},
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch is one line and its context, so that is all the two trees differ in.
|
||||
// A tree written in the form the platform checks files out in would differ from
|
||||
// the other in every line instead.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains(" one"),
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
).
|
||||
// git says nothing about a round trip these files never take.
|
||||
Content(DoesNotContain("warning"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discard a hunk of the working tree's diff from the focused main view, and unstage one from the staged half",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\nUNSTAGED\neight\nnine\nten\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("MM file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Discarding from the unstaged side throws the change away, so it asks first.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+UNSTAGED"),
|
||||
).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard change")).
|
||||
Content(Contains("Are you sure you want to discard this change")).
|
||||
Confirm()
|
||||
})
|
||||
|
||||
// Nothing is unstaged any more, so that pane is gone and the focus has followed
|
||||
// the file's remaining changes into the staged one.
|
||||
t.Views().Files().Lines(
|
||||
Contains("M file1"),
|
||||
)
|
||||
t.Views().Main().IsInvisible()
|
||||
|
||||
// There the same key means "I don't want this staged", which is unstaging, so it
|
||||
// doesn't ask.
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Title(Equals("Staged changes")).
|
||||
SelectedLines(
|
||||
Contains("+STAGED"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.Views().Files().Lines(
|
||||
Contains(" M file1"),
|
||||
)
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Title(Equals("Unstaged changes")).
|
||||
Content(Contains("+STAGED"))
|
||||
},
|
||||
})
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardFromACommitOnlyWhereItCanBeRewritten = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discarding lines is refused over a diff that belongs to no commit we may rewrite, and over the custom patch itself",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFile("file1", "one\nTWO\nthree\n")
|
||||
shell.Stash("a stashed change")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// A stash entry is no commit of ours to rewrite, so its lines can go into a
|
||||
// patch but can't be taken out of what they are part of.
|
||||
t.Views().Stash().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("a stashed change").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Changes can only be discarded from local commits")).
|
||||
Confirm()
|
||||
|
||||
// The pane previewing the patch shows the patch's own lines, which are not the
|
||||
// commit's to discard; space takes them back out of the patch instead.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Lines shown here are the custom patch's")).
|
||||
Confirm()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardLinesFromACommit = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discard the selected lines of a commit's diff from the commit itself, in 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("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\nFOUR\nfive\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+TWO")).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard lines from commit")).
|
||||
Content(Contains("Are you sure you want to discard the selected lines from this commit?")).
|
||||
Confirm()
|
||||
|
||||
// The commit keeps its other change and has given up the one discarded, and the
|
||||
// selection carries on from the change that has taken its place.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+TWO")).
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
)
|
||||
|
||||
// The rewrite is the commit's own business: nothing is left lying in the working
|
||||
// tree.
|
||||
t.Views().Files().IsEmpty()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var EditHunkInFocusedDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Edit the hunk around the selection in an editor, and stage what comes back",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// Stand in for the editor: record the line it was pointed at, outside the
|
||||
// repo so that the files panel keeps saying what the test is about, then
|
||||
// write a patch that stages something neither side of the diff says. That
|
||||
// is the point of editing a hunk.
|
||||
cfg.GetUserConfig().OS.EditAtLineAndWait = "echo {{line}} > ../edit-line && " +
|
||||
"printf '%s\\n' '--- a/file1' '+++ b/file1' '@@ -1,3 +1,3 @@' " +
|
||||
"' one' '-two' '+TWO_EDITED' ' three' > {{filename}}"
|
||||
},
|
||||
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().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(Contains("-two")).
|
||||
Press(keys.Main.EditSelectHunk)
|
||||
|
||||
// The patch is written with a two-line header, so the deletion the cursor was
|
||||
// on is its fifth line.
|
||||
t.FileSystem().FileContent("../edit-line", Equals("5\n"))
|
||||
|
||||
// What the editor wrote went into the index, leaving the working tree as it
|
||||
// was: the file is changed on both sides now, differently.
|
||||
t.Views().Files().Lines(
|
||||
Contains("MM").Contains("file1"),
|
||||
)
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO_EDITED"),
|
||||
)
|
||||
t.Views().Main().ContainsLines(
|
||||
Contains("-TWO_EDITED"),
|
||||
Contains("+TWO"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusFollowsAPaneEmptiedFromOutside = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Committing the staged changes outside lazygit takes the staged pane away, so the focus follows into the one that is left",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
// One change on each side, so the diff is split.
|
||||
shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\n")
|
||||
shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nUNSTAGED\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.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Title(Equals("Staged changes")).
|
||||
SelectedLines(
|
||||
Contains("+STAGED"),
|
||||
)
|
||||
|
||||
// Nothing lazygit did empties the staged side here; the refresh simply finds
|
||||
// it empty, and the pane the focus was in is gone by the time it lands.
|
||||
t.Shell().Commit("two")
|
||||
t.GlobalPress(keys.Universal.Refresh)
|
||||
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Title(Equals("Unstaged changes")).
|
||||
SelectedLines(
|
||||
Contains("+UNSTAGED"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusFollowsIntoAPaneTakingOver = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The pane the focus follows into as it takes the section over gets its selection from the top of the diff it is given",
|
||||
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")
|
||||
|
||||
// Everything staged and nothing unstaged, so the staged side has the section to
|
||||
// itself, with more changes in it than fit.
|
||||
for i := range lines {
|
||||
lines[i] = strings.ToUpper(lines[i])
|
||||
}
|
||||
shell.UpdateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("M file1").IsSelected(),
|
||||
)
|
||||
|
||||
// Read a way into the diff before focusing it, so that the selection starts out
|
||||
// somewhere other than the first change.
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
ScrollWheelDown().
|
||||
OriginY(8)
|
||||
|
||||
t.Views().Files().Press(keys.Universal.FocusMainView)
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-line04"),
|
||||
)
|
||||
|
||||
// The index is reset outside lazygit, so the staged side empties and the
|
||||
// unstaged side has everything: the pane the focus is in goes away and the other
|
||||
// one takes the section over with a diff that is new to it.
|
||||
t.Shell().RunCommand([]string{"git", "reset"})
|
||||
t.GlobalPress(keys.Universal.Refresh)
|
||||
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Title(Equals("Unstaged changes")).
|
||||
OriginY(0).
|
||||
SelectedLines(
|
||||
Contains("-line01"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusFollowsStagedSide = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Unstaging from a fully staged file leaves the focus on the staged side, which keeps its pane",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.Commit("one")
|
||||
|
||||
// Two staged additions and nothing unstaged, so only the pane the staged side
|
||||
// lives in is shown.
|
||||
shell.UpdateFileAndAdd("file1", "one\nSTAGED1\ntwo\nthree\nfour\nfive\nsix\nseven\nSTAGED2\neight\nnine\nten\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("M file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().IsInvisible()
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Title(Equals("Staged changes")).
|
||||
SelectedLines(
|
||||
Contains("+STAGED1"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The line taken out of the index turns up in the pane that has just appeared
|
||||
// above, and the work carries on where it was, on the next staged change.
|
||||
t.Views().Files().Lines(
|
||||
Contains("MM file1"),
|
||||
)
|
||||
t.Views().Main().
|
||||
IsVisible().
|
||||
Content(Contains("+STAGED1"))
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+STAGED2"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusFollowsWhenPaneGoes = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Staging the last unstaged change takes the upper pane away, so the focus follows the lines into the lower one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\nADDED\ntwo\nthree\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Title(Equals("Unstaged changes")).
|
||||
SelectedLines(
|
||||
Contains("+ADDED"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// Nothing is unstaged any more, so that pane is gone and the line is in the one
|
||||
// below, where the focus and the selection now are.
|
||||
t.Views().Files().Lines(
|
||||
Contains("M file1"),
|
||||
)
|
||||
t.Views().Main().IsInvisible()
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Title(Equals("Staged changes")).
|
||||
SelectedLines(
|
||||
Contains("+ADDED"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusLeavesAnAlwaysSplitEmptyPane = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Configured to always split the diff, the emptied pane stays but the focus still leaves it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
cfg.GetUserConfig().Gui.SplitDiff = "always"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\n")
|
||||
shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nUNSTAGED\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.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+STAGED"),
|
||||
)
|
||||
|
||||
t.Shell().Commit("two")
|
||||
t.GlobalPress(keys.Universal.Refresh)
|
||||
|
||||
// The staged side is empty now, but its pane is still shown because the split
|
||||
// is configured as permanent. There is nothing left to act on in it, so the
|
||||
// focus goes where there is.
|
||||
t.Views().Secondary().
|
||||
IsVisible().
|
||||
Content(DoesNotContain("STAGED"))
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+UNSTAGED"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FocusReturnsWhenSplitCollapses = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Unstaging the last staged change from the secondary pane brings the focus back to the main one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.Commit("one")
|
||||
|
||||
// One change on each side, so the diff is split.
|
||||
shell.UpdateFileAndAdd("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.UpdateFile("file1", "one\nSTAGED\ntwo\nthree\nfour\nfive\nsix\nseven\nUNSTAGED\neight\nnine\nten\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("MM file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+STAGED"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// With nothing staged left the diff isn't split any more, so the pane that was
|
||||
// showing the staged side is gone — and the focus is back on the main one, where
|
||||
// the change just taken out of the index now is.
|
||||
t.Views().Files().Lines(
|
||||
Contains(" M file1"),
|
||||
)
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("+STAGED")).
|
||||
Content(Contains("+UNSTAGED")).
|
||||
SelectedLines(
|
||||
Contains("+STAGED"),
|
||||
)
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user