mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-29 02:35:39 -04:00
Compare commits
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"additionalDirectories": [
|
||||
"/Users/stk/Stk/Dev/Builds/delta",
|
||||
"/Users/stk/Stk/Dev/Builds/difftastic"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,6 +371,12 @@ git:
|
||||
# # renderer command.
|
||||
# name: ""
|
||||
#
|
||||
# # If true, selected diff lines are highlighted only with a narrow bar
|
||||
# # at the left edge of the view, rather than across the whole width.
|
||||
# # Useful for pagers whose own line background colors should remain
|
||||
# # visible.
|
||||
# narrowSelectionHighlight: false
|
||||
#
|
||||
# # Value of the --color arg in the git diff command. Only used for type
|
||||
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
# # want it set to 'never'.
|
||||
|
||||
@@ -224,6 +224,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Main panel (patch building)
|
||||
@@ -329,6 +338,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <left>, h `` | Go to previous hunk | |
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -193,6 +193,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## タグ
|
||||
@@ -306,6 +315,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
|
||||
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` <space> `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 |
|
||||
| `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 |
|
||||
| `` <ctrl+o> `` | 選択したテキストをクリップボードにコピー | |
|
||||
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 範囲選択を切り替え | |
|
||||
| `` <left>, h `` | 前のハンクに移動 | |
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
## メニュー
|
||||
|
||||
@@ -85,6 +85,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## Stash
|
||||
@@ -163,6 +172,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
|
||||
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | 선택한 텍스트를 클립보드에 복사 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 드래그 선택 전환 | |
|
||||
| `` <left>, h `` | 이전 hunk를 선택 | |
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
## 메인 패널 (Patch Building)
|
||||
|
||||
@@ -232,6 +232,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Patch bouwen
|
||||
@@ -307,6 +316,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
## Staging
|
||||
|
||||
@@ -100,6 +100,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Drzewa pracy
|
||||
@@ -202,6 +211,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
|
||||
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Kopiuj zaznaczony tekst do schowka | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Przełącz zaznaczenie zakresu | |
|
||||
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
## Panel główny (scalanie)
|
||||
|
||||
@@ -236,6 +236,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <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 | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Painel Principal (preparação)
|
||||
@@ -338,6 +347,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <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. |
|
||||
| `` <ctrl+o> `` | Copiar texto selecionado para área de transferência | |
|
||||
| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. |
|
||||
| `` v `` | Toggle range select | |
|
||||
| `` <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 | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
## Stash
|
||||
|
||||
@@ -75,6 +75,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Индексирование)
|
||||
@@ -107,6 +116,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
|
||||
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные |
|
||||
| `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | Скопировать выделенный текст в буфер обмена | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | Переключить выборку перетаскивания | |
|
||||
| `` <left>, h `` | Выбрать предыдущую часть | |
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
## Главная панель (Слияние)
|
||||
|
||||
@@ -287,6 +287,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 正在合并
|
||||
@@ -335,6 +344,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` <space> `` | 切换暂存状态 | 切换行暂存状态 |
|
||||
| `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 |
|
||||
| `` <ctrl+o> `` | 复制选中文本到剪贴板 | |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` <left>, h `` | 选择上一个区块 | |
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
## 状态
|
||||
|
||||
@@ -83,6 +83,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 主面板(合併)
|
||||
@@ -364,6 +373,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` <space> `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) |
|
||||
| `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
| `` <ctrl+o> `` | 複製所選文本至剪貼簿 | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <left>, h `` | 選擇上一段 | |
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
## 狀態
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,9 @@ func NewGitCommandAux(
|
||||
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() (string, error) {
|
||||
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
|
||||
})
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
@@ -240,10 +240,10 @@ 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, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
cmdArgs := NewGitCmd("show").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
Arg("--submodule").
|
||||
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
|
||||
Arg("--stat").
|
||||
|
||||
@@ -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, false).Run())
|
||||
runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/mgutz/str"
|
||||
)
|
||||
|
||||
type DiffCommands struct {
|
||||
@@ -16,13 +19,118 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
|
||||
}
|
||||
}
|
||||
|
||||
// metadataHandshake is the OSC sequence a metadata-aware pager emits, as its first
|
||||
// output, to announce that it speaks the diff-line-metadata protocol: a version-only
|
||||
// OSC 1717 record (no fields). See ProbePagerEmitsDiffMetadata and, for how it's
|
||||
// swallowed on a real render, escapeInterpreter.dropMetadataIfHandshake.
|
||||
const metadataHandshake = "\x1b]1717"
|
||||
|
||||
// ProbePagerEmitsDiffMetadata reports whether the configured diff renderer speaks the
|
||||
// diff-line-metadata protocol, by running it on empty input and checking for its
|
||||
// handshake. It's the focused main view's signal for whether it can act on the rendered
|
||||
// diff or must fall back to the raw diff (see
|
||||
// StagingHelper.DiffMainViewShouldRenderRaw). The verdict is content-independent — the
|
||||
// handshake is the renderer's first output regardless of the diff — so the caller caches
|
||||
// it per renderer.
|
||||
//
|
||||
// No PTY is needed: git needs a terminal to decide to invoke a pager, but a renderer
|
||||
// emits the handshake whenever OSC1717 is set, so we can run it directly with empty
|
||||
// input.
|
||||
//
|
||||
// A git-config external diff driver (useExternalDiffGitConfig) is chosen per file via
|
||||
// .gitattributes and a single diff can mix drivers, so there's no one renderer to probe;
|
||||
// we conservatively report false (the focused main view then always renders raw).
|
||||
func (self *DiffCommands) ProbePagerEmitsDiffMetadata() bool {
|
||||
if extDiffCmd := self.diffRendererConfigManager.GetExternalDiffCommand(3); extDiffCmd != "" {
|
||||
return self.externalDiffEmitsMetadata(extDiffCmd)
|
||||
}
|
||||
if pagerCmd := self.diffRendererConfigManager.GetStdinFilterCommand(0); pagerCmd != "" {
|
||||
return self.probeEmitsMetadata(self.cmd.NewShell(pagerCmd, ""))
|
||||
}
|
||||
if rawGitArgs := self.diffRendererConfigManager.GetRawGitArgs(); len(rawGitArgs) > 0 {
|
||||
return self.rawGitEmitsMetadata(rawGitArgs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rawGitEmitsMetadata probes git itself, run with the diff renderer's own arguments.
|
||||
//
|
||||
// The arguments are the point: git describes only the formats whose output can't be read
|
||||
// back from its own text, and announces itself for exactly those, so the same git answers
|
||||
// differently depending on what it is asked for — a handshake for --color-words, silence
|
||||
// for a unified diff.
|
||||
func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool {
|
||||
oldPath, newPath, cleanup, ok := 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 probes an external diff command, invoking it the way git
|
||||
// invokes a diff.external driver — with 7 positional args
|
||||
// (path old-file old-hex old-mode new-file new-hex new-mode) — but on two empty temp
|
||||
// files, so it emits its handshake without there being a real diff to render.
|
||||
func (self *DiffCommands) externalDiffEmitsMetadata(extDiffCmd string) bool {
|
||||
oldPath, newPath, cleanup, ok := probeFiles()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
args := append(str.ToArgv(extDiffCmd),
|
||||
"probe", oldPath, "0000000", "100644", newPath, "0000000", "100644")
|
||||
return self.probeEmitsMetadata(self.cmd.New(args))
|
||||
}
|
||||
|
||||
// probeFiles creates the two empty temp files a probe stands a diff up from, and a
|
||||
// cleanup that removes them. Empty because a probe needs the renderer to announce
|
||||
// itself, not to have anything to render.
|
||||
func probeFiles() (string, string, func(), bool) {
|
||||
oldFile, err := os.CreateTemp("", "lazygit-probe-old-*")
|
||||
if err != nil {
|
||||
return "", "", nil, false
|
||||
}
|
||||
oldFile.Close()
|
||||
|
||||
newFile, err := os.CreateTemp("", "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")
|
||||
// The pager may exit non-zero on the synthetic input; we only care about whether
|
||||
// it emitted the handshake first, and the output is captured either way.
|
||||
output, _ := cmdObj.RunWithOutput()
|
||||
return strings.Contains(output, metadataHandshake)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// diff to the main view). It uses a custom diff renderer if one is configured, unless
|
||||
// ignoreExternalDiff is set (the focused main view's raw-diff fallback; keeps the
|
||||
// colour, unlike a plain diff).
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(diffArgs...).
|
||||
@@ -31,6 +139,27 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
|
||||
)
|
||||
}
|
||||
|
||||
// CustomPatchDiffCmdObj builds the command that renders the custom patch shown in the
|
||||
// secondary pane: a `git diff --no-index` of the two file trees PatchCommands materialized
|
||||
// under dir (a/ = before, b/ = after; see WriteCustomPatchDiffTrees). It uses the same pager
|
||||
// wiring as DiffCmdObj so the patch renders exactly like any other diff — through a stdin
|
||||
// pager, an external diff tool, or (when ignoreExternalDiff is set, the focused main view's
|
||||
// raw-diff fallback) git's own colour. --no-prefix is used because the a/ and b/ tree names
|
||||
// already stand in for git's conventional a//b/ path prefixes, so the diff's paths come out
|
||||
// as the real repo-relative paths.
|
||||
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !ignoreExternalDiff).
|
||||
Arg("--no-index").
|
||||
Arg("--no-prefix").
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,9 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
@@ -20,6 +22,11 @@ type PatchCommands struct {
|
||||
stash *StashCommands
|
||||
|
||||
PatchBuilder *patch.PatchBuilder
|
||||
|
||||
// lastBuiltTreeGeneration is the patch builder generation the diff trees were last
|
||||
// materialized for, so EnsureCustomPatchDiffTrees rebuilds them only when the patch
|
||||
// actually changed (not on every render / navigation).
|
||||
lastBuiltTreeGeneration int
|
||||
}
|
||||
|
||||
func NewPatchCommands(
|
||||
@@ -40,6 +47,91 @@ func NewPatchCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCustomPatchDiffTrees materializes the custom patch's diff trees if they're stale —
|
||||
// i.e. the patch changed since they were last built. Called before rendering the secondary
|
||||
// pane, so a mere re-render (e.g. navigating commits, which doesn't change the patch) reuses
|
||||
// the existing trees, while a toggle/removal/whole-file change rebuilds them. This is what
|
||||
// keeps the trees current across every path that mutates the patch.
|
||||
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
|
||||
if self.PatchBuilder.Generation() == self.lastBuiltTreeGeneration {
|
||||
return nil
|
||||
}
|
||||
if err := self.WriteCustomPatchDiffTrees(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.lastBuiltTreeGeneration = self.PatchBuilder.Generation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteCustomPatchDiffTrees materializes the custom patch under the patch builder's temp
|
||||
// dir as two file trees — a/ holds each patched file's "from"-side content, b/ holds that
|
||||
// content with the patch applied — so the patch can be re-diffed with `git diff --no-index`
|
||||
// and rendered through any pager (see DiffCommands.CustomPatchDiffCmdObj). This is what lets
|
||||
// a partial, in-memory custom patch be shown the same way as any other diff; the in-memory
|
||||
// aggregated patch on its own could only be fed to a stdin pager, never an external diff
|
||||
// tool. The dirs are named a/b so that, with `--no-prefix`, the diff's paths come out as the
|
||||
// real repo-relative paths (git's conventional a//b/ prefixes).
|
||||
//
|
||||
// Called whenever the patch's contents change. The temp dir's lifetime is owned by the
|
||||
// patch builder (created on Start, removed on Reset), so there's nothing to clean up here
|
||||
// beyond wiping the trees before rebuilding them.
|
||||
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
|
||||
dir := self.PatchBuilder.TempDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
aDir := filepath.Join(dir, "a")
|
||||
bDir := filepath.Join(dir, "b")
|
||||
for _, d := range []string{aDir, bDir} {
|
||||
if err := os.RemoveAll(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, filename := range self.PatchBuilder.ActiveFilenames() {
|
||||
// The "from"-side content; the patch applied below turns b/ into the "after" side
|
||||
// while a/ stays the "before".
|
||||
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, filename).RunWithOutput()
|
||||
added := err != nil // absent on the "from" side — a file the patch adds
|
||||
|
||||
// a/ always holds the "before" content — empty for an added file, so the rendered
|
||||
// diff still pairs it with b/ and shows the real a//b/ paths (rather than git's
|
||||
// directory-comparison "added in b" form, which mangles the header).
|
||||
beforeContent := content
|
||||
if added {
|
||||
beforeContent = ""
|
||||
}
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(aDir, filename), beforeContent); err != nil {
|
||||
return err
|
||||
}
|
||||
// b/ is seeded only for existing files (which the patch modifies in place); an added
|
||||
// file is left absent so the patch — rendered as a /dev/null creation — creates it.
|
||||
if !added {
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(bDir, filename), content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render added files as /dev/null creations (the natural form), not as diffs against an
|
||||
// empty file: the latter (--- a/file) would make the atomic `git apply` expect them to
|
||||
// already exist in b/, where they don't.
|
||||
patchText := self.PatchBuilder.PatchToApply(false, false)
|
||||
if strings.TrimSpace(patchText) == "" {
|
||||
// An empty patch leaves a/ and b/ identical (or empty), so the diff is empty.
|
||||
return nil
|
||||
}
|
||||
patchFilePath, err := self.SaveTemporaryPatch(patchText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return self.cmd.New(NewGitCmd("apply").Arg(patchFilePath).Dir(bDir).ToArgv()).Run()
|
||||
}
|
||||
|
||||
type ApplyPatchOpts struct {
|
||||
ThreeWay bool
|
||||
Cached bool
|
||||
|
||||
@@ -80,10 +80,10 @@ 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, ignoreExternalDiff bool) *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(), !ignoreExternalDiff).
|
||||
Arg("-p").
|
||||
Arg("--stat").
|
||||
Arg("-u").
|
||||
|
||||
@@ -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, false).Args()
|
||||
assert.Equal(t, s.expected, cmdStr)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,14 +385,17 @@ 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 {
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, false, nil).RunWithOutput()
|
||||
return s
|
||||
}
|
||||
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
|
||||
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
|
||||
// the node's path (used to diff only filtered/visible files within a directory).
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
// ignoreExternalDiff forces git's own (coloured) diff regardless of a configured
|
||||
// external diff command, for the focused main view's raw-diff fallback (see
|
||||
// StagingHelper.DiffMainViewShouldRenderRaw); unlike plain it keeps the colour.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, ignoreExternalDiff bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
@@ -407,7 +410,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
ArgIf(cached, "--cached").
|
||||
@@ -431,10 +434,10 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
|
||||
if previousPath != "" {
|
||||
fileNames = append(fileNames, previousPath)
|
||||
}
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput()
|
||||
return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain, false).RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool, ignoreExternalDiff bool) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
@@ -442,7 +445,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain && !ignoreExternalDiff).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
Arg(from).
|
||||
|
||||
@@ -16,6 +16,11 @@ type Hunk struct {
|
||||
newStart int
|
||||
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
|
||||
headerContext string
|
||||
// the lengths declared in the hunk header ('2' and '3' in the above example),
|
||||
// kept so we can verify the parsed body actually matches the header (see
|
||||
// Patch.IsWellFormed). Only populated by Parse.
|
||||
declaredOldLength int
|
||||
declaredNewLength int
|
||||
// the body of the hunk, excluding the header line
|
||||
bodyLines []*PatchLine
|
||||
}
|
||||
|
||||
+26
-11
@@ -7,7 +7,9 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
|
||||
// Captures, in order: old start, old length (omitted when 1), new start, new
|
||||
// length (omitted when 1), and the trailing context.
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
|
||||
|
||||
func Parse(patchStr string) *Patch {
|
||||
// ignore trailing newline.
|
||||
@@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
|
||||
var currentHunk *Hunk
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
oldStart, newStart, headerContext := headerInfo(line)
|
||||
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
|
||||
|
||||
currentHunk = &Hunk{
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
declaredOldLength: oldLength,
|
||||
declaredNewLength: newLength,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
}
|
||||
hunks = append(hunks, currentHunk)
|
||||
} else if currentHunk != nil {
|
||||
@@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
|
||||
}
|
||||
}
|
||||
|
||||
func headerInfo(header string) (int, int, string) {
|
||||
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
|
||||
match := hunkHeaderRegexp.FindStringSubmatch(header)
|
||||
|
||||
oldStart := utils.MustConvertToInt(match[1])
|
||||
newStart := utils.MustConvertToInt(match[2])
|
||||
headerContext := match[3]
|
||||
oldStart = utils.MustConvertToInt(match[1])
|
||||
oldLength = declaredLength(match[2])
|
||||
newStart = utils.MustConvertToInt(match[3])
|
||||
newLength = declaredLength(match[4])
|
||||
headerContext = match[5]
|
||||
|
||||
return oldStart, newStart, headerContext
|
||||
return oldStart, oldLength, newStart, newLength, headerContext
|
||||
}
|
||||
|
||||
// declaredLength parses a hunk header length capture, which git omits when it
|
||||
// is 1 (e.g. "@@ -0,0 +1 @@").
|
||||
func declaredLength(match string) int {
|
||||
if match == "" {
|
||||
return 1
|
||||
}
|
||||
return utils.MustConvertToInt(match)
|
||||
}
|
||||
|
||||
func newHunkLine(line string) *PatchLine {
|
||||
|
||||
@@ -79,6 +79,23 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
||||
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
|
||||
}
|
||||
|
||||
// IsWellFormed reports whether every hunk's body matches the lengths declared
|
||||
// in its header. A faithful unified diff always satisfies this. A rendering that
|
||||
// restructured the diff body does not — e.g. delta with line-number gutters
|
||||
// shifts the +/- marker off the start of each line, so every body line reads as
|
||||
// context and the computed lengths no longer match the header. This lets a
|
||||
// parser tell a real unified diff from a mangled one and fall back rather than
|
||||
// trust a mis-parse. Only meaningful for patches produced by Parse (the lengths
|
||||
// are read from the header there).
|
||||
func (self *Patch) IsWellFormed() bool {
|
||||
for _, hunk := range self.hunks {
|
||||
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *Patch) ContainsChanges() bool {
|
||||
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
|
||||
return hunk.containsChanges()
|
||||
@@ -114,6 +131,105 @@ func (self *Patch) LineNumberOfLine(idx int) int {
|
||||
return hunk.newStart + offset
|
||||
}
|
||||
|
||||
// Takes a line index in the patch and returns the line number in the old file.
|
||||
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
|
||||
// the line's position in the old file (additions get the position they sit at).
|
||||
// If the line is a header line, returns 1.
|
||||
// If the line is a hunk header line, returns the first old-file line number in
|
||||
// that hunk.
|
||||
// If the line is out of range below, returns the last old-file line number in
|
||||
// the last hunk.
|
||||
func (self *Patch) OldLineNumberOfLine(idx int) int {
|
||||
if idx < len(self.header) || len(self.hunks) == 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
hunkIdx := self.HunkContainingLine(idx)
|
||||
// cursor out of range, return last file line number
|
||||
if hunkIdx == -1 {
|
||||
lastHunk := self.hunks[len(self.hunks)-1]
|
||||
return lastHunk.oldStart + lastHunk.oldLength() - 1
|
||||
}
|
||||
|
||||
hunk := self.hunks[hunkIdx]
|
||||
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
||||
idxInHunk := idx - hunkStartIdx
|
||||
|
||||
if idxInHunk == 0 {
|
||||
return hunk.oldStart
|
||||
}
|
||||
|
||||
lines := hunk.bodyLines[:idxInHunk-1]
|
||||
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
|
||||
return hunk.oldStart + offset
|
||||
}
|
||||
|
||||
// Takes a line number in the new file and returns the line index in the patch.
|
||||
// This is the opposite of LineNumberOfLine.
|
||||
// If the line number is not contained in any of the hunks, it returns the
|
||||
// closest position.
|
||||
func (self *Patch) PatchLineForLineNumber(lineNumber int) int {
|
||||
if len(self.hunks) == 0 {
|
||||
return len(self.header)
|
||||
}
|
||||
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
if lineNumber <= hunk.newStart {
|
||||
return self.HunkStartIdx(hunkIdx)
|
||||
}
|
||||
|
||||
if lineNumber < hunk.newStart+hunk.newLength() {
|
||||
lines := hunk.bodyLines
|
||||
offset := lineNumber - hunk.newStart
|
||||
for i, line := range lines {
|
||||
if offset == 0 {
|
||||
return self.HunkStartIdx(hunkIdx) + i + 1
|
||||
}
|
||||
|
||||
if line.Kind == ADDITION || line.Kind == CONTEXT {
|
||||
offset--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self.LineCount() - 1
|
||||
}
|
||||
|
||||
// Takes a line number in the old file and returns the line index in the patch.
|
||||
// This is the old-file counterpart of PatchLineForLineNumber. It is what lets us
|
||||
// land on the right patch line for a deletion: two consecutive deletions share a
|
||||
// new-file line number, so only the old-file number tells them apart.
|
||||
// If the line number is not contained in any of the hunks, it returns the
|
||||
// closest position.
|
||||
func (self *Patch) PatchLineForOldLineNumber(lineNumber int) int {
|
||||
if len(self.hunks) == 0 {
|
||||
return len(self.header)
|
||||
}
|
||||
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
if lineNumber <= hunk.oldStart {
|
||||
return self.HunkStartIdx(hunkIdx)
|
||||
}
|
||||
|
||||
if lineNumber < hunk.oldStart+hunk.oldLength() {
|
||||
lines := hunk.bodyLines
|
||||
offset := lineNumber - hunk.oldStart
|
||||
for i, line := range lines {
|
||||
if offset == 0 {
|
||||
return self.HunkStartIdx(hunkIdx) + i + 1
|
||||
}
|
||||
|
||||
if line.Kind == DELETION || line.Kind == CONTEXT {
|
||||
offset--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self.LineCount() - 1
|
||||
}
|
||||
|
||||
// Returns hunk index containing the line at the given patch line index
|
||||
func (self *Patch) HunkContainingLine(idx int) int {
|
||||
for hunkIdx, hunk := range self.hunks {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -60,12 +61,26 @@ type PatchBuilder struct {
|
||||
|
||||
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
|
||||
loadFileDiff loadFileDiffFunc
|
||||
|
||||
// newTempDir creates a fresh temp dir for the current patch, into which the patch is
|
||||
// materialized as two file trees so it can be rendered through any pager (see
|
||||
// PatchCommands.WriteCustomPatchDiffTrees). The patch builder owns its lifetime: a dir
|
||||
// is created on Start and removed on Reset. Nil in tests that don't render the patch.
|
||||
newTempDir func() (string, error)
|
||||
tempDir string
|
||||
|
||||
// generation is bumped on every change to the patch's contents, so a consumer that
|
||||
// materializes the patch (the secondary pane's two file trees) can tell when it's stale
|
||||
// and rebuild only then — covering every path that mutates the patch (the focused main
|
||||
// view and the old explorer alike) without rebuilding on mere navigation.
|
||||
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 +88,16 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.removeTempDir()
|
||||
if p.newTempDir != nil {
|
||||
if dir, err := p.newTempDir(); err != nil {
|
||||
p.Log.Error(err)
|
||||
} else {
|
||||
p.tempDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
p.To = to
|
||||
p.From = from
|
||||
p.reverse = reverse
|
||||
@@ -91,6 +116,37 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
|
||||
return p.fileInfoMap
|
||||
}
|
||||
|
||||
// TempDir is the directory the current patch is materialized into for rendering, or "" if
|
||||
// none was created. See PatchCommands.WriteCustomPatchDiffTrees.
|
||||
func (p *PatchBuilder) TempDir() string {
|
||||
return p.tempDir
|
||||
}
|
||||
|
||||
// Generation is bumped each time the patch's contents change; see the field comment.
|
||||
func (p *PatchBuilder) Generation() int {
|
||||
return p.generation
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) removeTempDir() {
|
||||
if p.tempDir != "" {
|
||||
_ = os.RemoveAll(p.tempDir)
|
||||
p.tempDir = ""
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveFilenames returns the files currently part of the patch (mode != UNSELECTED), in
|
||||
// sorted order — the files to materialize when rendering the patch.
|
||||
func (p *PatchBuilder) ActiveFilenames() []string {
|
||||
filenames := make([]string, 0, len(p.fileInfoMap))
|
||||
for filename, info := range p.fileInfoMap {
|
||||
if info.mode != UNSELECTED {
|
||||
filenames = append(filenames, filename)
|
||||
}
|
||||
}
|
||||
sort.Strings(filenames)
|
||||
return filenames
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
|
||||
var patch strings.Builder
|
||||
|
||||
@@ -135,6 +191,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.addFileWhole(info)
|
||||
|
||||
return nil
|
||||
@@ -146,6 +203,8 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
|
||||
p.removeFile(info)
|
||||
|
||||
return nil
|
||||
@@ -182,6 +241,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 +253,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 {
|
||||
@@ -299,11 +360,109 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
|
||||
return info.includedLineIndices, nil
|
||||
}
|
||||
|
||||
// LineIdentity identifies a change line (an addition or deletion) by its file line
|
||||
// number and whether it's a deletion, independently of the line's index in the parsed
|
||||
// patch. It is the identity the diff-line metadata resolves a rendered row to (see
|
||||
// types.DiffLineInfo.PatchSelectLine), and lets the focused main view toggle patch
|
||||
// membership and drive the inclusion gutter without dealing in patch-line indices —
|
||||
// which differ between the raw diff and however a pager renders it.
|
||||
type LineIdentity struct {
|
||||
LineNumber int
|
||||
IsDeletion bool
|
||||
}
|
||||
|
||||
// changeLineIndexByIdentity scans a parsed diff and returns, for each change line, its
|
||||
// index in the patch keyed by the line's identity. An addition is keyed by its new-file
|
||||
// line number, a deletion by its old-file line number, so each change line has a
|
||||
// distinct identity (two consecutive deletions share a new-file number but differ in
|
||||
// the old-file one).
|
||||
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
|
||||
}
|
||||
|
||||
// PatchLineIndicesForLines maps the given change-line identities to their indices in
|
||||
// filename's parsed diff — the index form that AddFileLineRange / RemoveFileLineRange
|
||||
// and GetFileIncLineIndices work in. Identities that don't correspond to a change line
|
||||
// (e.g. a context line) are skipped. It is how the focused main view, which knows a
|
||||
// selection only as metadata identities, drives patch building.
|
||||
func (p *PatchBuilder) PatchLineIndicesForLines(filename string, lines []LineIdentity) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIdentity := changeLineIndexByIdentity(Parse(info.diff))
|
||||
indices := make([]int, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if idx, ok := byIdentity[line]; ok {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
// IncludedChangeLineIndices returns the patch-line indices of the change lines (additions
|
||||
// and deletions) currently included in the patch for filename, in ascending order. These
|
||||
// are exactly the change lines the aggregated patch renders for the file, in the same
|
||||
// order, so the k-th change line shown in the custom-patch (secondary) view corresponds to
|
||||
// the k-th index here. That correspondence lets the focused main view remove a selection
|
||||
// from the patch by its ordinal among the shown change lines, sidestepping the line-number
|
||||
// renumbering the aggregated patch applies (which makes matching by identity unreliable for
|
||||
// additions). Empty when the file isn't part of the patch.
|
||||
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
|
||||
info, ok := p.fileInfoMap[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
lines := Parse(info.diff).Lines()
|
||||
included := append([]int{}, info.includedLineIndices...)
|
||||
sort.Ints(included)
|
||||
result := make([]int, 0, len(included))
|
||||
for _, idx := range included {
|
||||
if idx >= 0 && idx < len(lines) && (lines[idx].IsAddition() || lines[idx].IsDeletion()) {
|
||||
result = append(result, idx)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IncludedLineIdentities returns the identities of the change lines currently included
|
||||
// in the patch for filename — the identity space the inclusion gutter matches rendered
|
||||
// rows against. Empty when the file isn't part of the patch.
|
||||
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
|
||||
info, ok := p.fileInfoMap[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
includedIdx := make(map[int]bool, len(info.includedLineIndices))
|
||||
for _, idx := range info.includedLineIndices {
|
||||
includedIdx[idx] = true
|
||||
}
|
||||
var identities []LineIdentity
|
||||
for identity, idx := range changeLineIndexByIdentity(Parse(info.diff)) {
|
||||
if includedIdx[idx] {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// clears the patch
|
||||
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,53 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newTestPatchBuilder returns a PatchBuilder whose files all resolve to the given
|
||||
// diff, started for a dummy commit.
|
||||
func newTestPatchBuilder(diff string) *PatchBuilder {
|
||||
pb := NewPatchBuilder(logrus.New().WithField("test", "test"),
|
||||
func(from, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
return diff, nil
|
||||
},
|
||||
nil)
|
||||
pb.Start("from", "to", false, true)
|
||||
return pb
|
||||
}
|
||||
|
||||
// In simpleDiff the deletion "-orange" is patch line index 6 (old-file line 2) and the
|
||||
// addition "+grape" is index 7 (new-file line 2).
|
||||
func TestPatchLineIndicesForLines(t *testing.T) {
|
||||
pb := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
indices, err := pb.PatchLineIndicesForLines("filename", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
{LineNumber: 1, IsDeletion: false}, // " apple" — a context line, no change index
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{6, 7}, indices, "context-line identity is skipped; change lines map to their indices")
|
||||
}
|
||||
|
||||
func TestIncludedLineIdentities(t *testing.T) {
|
||||
pb := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// Nothing included yet.
|
||||
assert.Empty(t, pb.IncludedLineIdentities("filename"))
|
||||
|
||||
// Include only the deletion: it comes back as its identity, the addition does not.
|
||||
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
|
||||
pb.IncludedLineIdentities("filename"))
|
||||
|
||||
// Including the addition too yields both identities (order-independent).
|
||||
assert.NoError(t, pb.AddFileLineRange("filename", "", []int{7}))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
pb.IncludedLineIdentities("filename"))
|
||||
}
|
||||
@@ -120,6 +120,20 @@ index 9320895..6d79956 100644
|
||||
lemon
|
||||
`
|
||||
|
||||
// Two deletions with no line between them: they share a new-file line number
|
||||
// (both sit at the same new-file position), so only their old-file line numbers
|
||||
// distinguish them.
|
||||
const consecutiveDeletions = `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
`
|
||||
|
||||
const newFile = `diff --git a/newfile b/newfile
|
||||
new file mode 100644
|
||||
index 0000000..4e680cc
|
||||
@@ -682,6 +696,95 @@ func TestLineNumberOfLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldLineNumberOfLine(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
indexes []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "twoChangesInOneHunk",
|
||||
patchStr: twoChangesInOneHunk,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
|
||||
},
|
||||
{
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
|
||||
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, idx := range s.indexes {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.OldLineNumberOfLine(idx)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchLineForOldLineNumber(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
patchStr string
|
||||
lineNumbers []int
|
||||
expecteds []int
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
// The two consecutive deletions (grape, pear) sit at patch line
|
||||
// indices 6 and 7; their old-file line numbers (2 and 3) keep them
|
||||
// distinct even though their new-file line numbers are both 2.
|
||||
testName: "consecutiveDeletions",
|
||||
patchStr: consecutiveDeletions,
|
||||
lineNumbers: []int{0, 1, 2, 3, 4, 5},
|
||||
expecteds: []int{4, 4, 6, 7, 8, 8},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
for i, lineNumber := range s.lineNumbers {
|
||||
patch := Parse(s.patchStr)
|
||||
result := patch.PatchLineForOldLineNumber(lineNumber)
|
||||
assert.Equal(t, s.expecteds[i], result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
assert.True(t, Parse(simpleDiff).IsWellFormed())
|
||||
assert.True(t, Parse(twoHunks).IsWellFormed())
|
||||
assert.True(t, Parse(consecutiveDeletions).IsWellFormed())
|
||||
assert.True(t, Parse(newFile).IsWellFormed())
|
||||
assert.True(t, Parse(deletedFile).IsWellFormed())
|
||||
assert.True(t, Parse(addNewlineToEndOfFile).IsWellFormed())
|
||||
|
||||
// A diff whose body has been mangled so the +/- markers no longer start each
|
||||
// line (as delta's line-number gutters do) reads as all-context, so the body
|
||||
// lengths no longer match the header.
|
||||
gutterMangled := `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
`
|
||||
assert.False(t, Parse(gutterMangled).IsWellFormed())
|
||||
}
|
||||
|
||||
func TestGetNextStageableLineIndex(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
||||
@@ -87,6 +87,14 @@ func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetNarrowSelectionHighlight() bool {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil {
|
||||
return false
|
||||
}
|
||||
return currentDiffRendererConfig.NarrowSelectionHighlight
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
|
||||
|
||||
@@ -70,6 +70,20 @@ func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
|
||||
assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr))
|
||||
}
|
||||
|
||||
func TestGetNarrowSelectionHighlight(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{{NarrowSelectionHighlight: true}}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
assert.Equal(t, true, config.GetNarrowSelectionHighlight())
|
||||
}
|
||||
|
||||
func TestGetNarrowSelectionHighlightWithoutPagers(t *testing.T) {
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
assert.Equal(t, false, config.GetNarrowSelectionHighlight())
|
||||
}
|
||||
|
||||
func TestCycleDiffRenderers(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||
|
||||
@@ -281,6 +281,12 @@ type GitConfig struct {
|
||||
// # renderer command.
|
||||
// name: ""
|
||||
//
|
||||
// # If true, selected diff lines are highlighted only with a narrow bar
|
||||
// # at the left edge of the view, rather than across the whole width.
|
||||
// # Useful for pagers whose own line background colors should remain
|
||||
// # visible.
|
||||
// narrowSelectionHighlight: false
|
||||
//
|
||||
// # Value of the --color arg in the git diff command. Only used for type
|
||||
// # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
// # want it set to 'never'.
|
||||
@@ -377,6 +383,8 @@ type DiffRendererConfig struct {
|
||||
Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"`
|
||||
// A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command.
|
||||
Name string `yaml:"name"`
|
||||
// If true, selected diff lines are highlighted only with a narrow bar at the left edge of the view, rather than across the whole width. Useful for pagers whose own line background colors should remain visible.
|
||||
NarrowSelectionHighlight bool `yaml:"narrowSelectionHighlight"`
|
||||
// Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'.
|
||||
ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"`
|
||||
// The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.
|
||||
|
||||
+95
-20
@@ -20,6 +20,24 @@ type escapeInterpreter struct {
|
||||
instruction instruction
|
||||
hyperlink strings.Builder
|
||||
|
||||
// the OSC number being accumulated while we don't yet know which OSC this is
|
||||
oscNumber strings.Builder
|
||||
// the payload of an OSC 1717 per-line diff-metadata sequence (see
|
||||
// diff-line-metadata-notes.md), accumulated like hyperlink
|
||||
metadata strings.Builder
|
||||
// OSC 1717 payloads that were superseded by the next record before any
|
||||
// cell consumed them. A row can carry several records back-to-back with
|
||||
// nothing rendered in between — difftastic's combined file+hunk banner, or
|
||||
// the deletion+addition pair of a modification row collapsed to a single
|
||||
// column — so a record's region can be zero-width. The write loop drains
|
||||
// these into content-less carrier cells, keeping every record of the row
|
||||
// discoverable instead of only the last one.
|
||||
orphanedMetadata []string
|
||||
// whether the payload currently in metadata has been stamped onto at
|
||||
// least one cell; an unconsumed payload is orphaned rather than dropped
|
||||
// when the next record arrives or the line ends
|
||||
metadataConsumed bool
|
||||
|
||||
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
|
||||
// rows rather than emitting LFs for them. To convert those into row
|
||||
// advances the view can act on, we track where in the pseudo-terminal
|
||||
@@ -82,9 +100,9 @@ const (
|
||||
stateParams
|
||||
stateCSIDiscard
|
||||
stateOSC
|
||||
stateOSCWaitForParams
|
||||
stateOSCParams
|
||||
stateOSCHyperlink
|
||||
stateOSCMetadata
|
||||
stateOSCEndEscape
|
||||
stateOSCSkipUnknown
|
||||
|
||||
@@ -427,27 +445,40 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
}
|
||||
return true, nil
|
||||
case stateOSC:
|
||||
if characterEquals(ch, '8') {
|
||||
ei.state = stateOSCWaitForParams
|
||||
ei.hyperlink.Reset()
|
||||
// Accumulate the OSC number until its terminating ';', then dispatch on
|
||||
// it. (The previous code only recognised the single-digit '8'; a number
|
||||
// like 1717 needs more than one character.)
|
||||
switch {
|
||||
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
|
||||
ei.oscNumber.WriteByte(ch[0])
|
||||
return true, nil
|
||||
case characterEquals(ch, ';'):
|
||||
switch ei.oscNumber.String() {
|
||||
case "8":
|
||||
ei.hyperlink.Reset()
|
||||
ei.state = stateOSCParams
|
||||
case "1717":
|
||||
ei.orphanUnconsumedMetadata()
|
||||
ei.state = stateOSCMetadata
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
ei.oscNumber.Reset()
|
||||
return true, nil
|
||||
default:
|
||||
// Not a recognized OSC; skip to its terminator (handling the case
|
||||
// where this character already is one).
|
||||
ei.oscNumber.Reset()
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.state = stateOSCSkipUnknown
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
case stateOSCWaitForParams:
|
||||
if !characterEquals(ch, ';') {
|
||||
// Malformed OSC 8 (expected ';' after '8'). Rather than
|
||||
// erroring — which would reset state mid-OSC and cause the
|
||||
// rest of the sequence to leak as literal text — treat the
|
||||
// whole OSC as one we don't understand and skip to its
|
||||
// terminator.
|
||||
ei.state = stateOSCSkipUnknown
|
||||
return true, nil
|
||||
}
|
||||
|
||||
ei.state = stateOSCParams
|
||||
return true, nil
|
||||
case stateOSCParams:
|
||||
if characterEquals(ch, ';') {
|
||||
ei.state = stateOSCHyperlink
|
||||
@@ -463,6 +494,18 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
ei.hyperlink.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCMetadata:
|
||||
switch {
|
||||
case characterEquals(ch, 0x07):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateNone
|
||||
case characterEquals(ch, 0x1b):
|
||||
ei.dropMetadataIfHandshake()
|
||||
ei.state = stateOSCEndEscape
|
||||
default:
|
||||
ei.metadata.Write(ch)
|
||||
}
|
||||
return true, nil
|
||||
case stateOSCEndEscape:
|
||||
ei.state = stateNone
|
||||
return true, nil
|
||||
@@ -478,6 +521,38 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// orphanUnconsumedMetadata prepares the metadata accumulator for a new OSC 1717
|
||||
// record. If the previous record's payload never made it onto a cell (its region
|
||||
// is zero-width because the next record follows immediately — see
|
||||
// orphanedMetadata), it is kept as an orphan rather than dropped.
|
||||
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
|
||||
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
|
||||
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
|
||||
}
|
||||
ei.metadata.Reset()
|
||||
ei.metadataConsumed = false
|
||||
}
|
||||
|
||||
// takeOrphanedMetadata hands the accumulated zero-width-record payloads (see
|
||||
// orphanedMetadata) to the caller and clears the list.
|
||||
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
|
||||
result := ei.orphanedMetadata
|
||||
ei.orphanedMetadata = nil
|
||||
return result
|
||||
}
|
||||
|
||||
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that carries no
|
||||
// fields (no ';'). A metadata-aware pager emits such a version-only record once, as
|
||||
// its first output, to announce it speaks the protocol (so we can probe it; see the
|
||||
// raw-diff fallback in the lazygit staging helper). It isn't per-line metadata, so it
|
||||
// must not linger in the accumulator and attach to the following line. Per-line
|
||||
// payloads always have fields, so they're kept.
|
||||
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
|
||||
if !strings.Contains(ei.metadata.String(), ";") {
|
||||
ei.metadata.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (ei *escapeInterpreter) outputCSI() error {
|
||||
n := len(ei.csiParam)
|
||||
for i := 0; i < n; {
|
||||
|
||||
+33
-10
@@ -86,6 +86,14 @@ type ViewMouseBinding struct {
|
||||
|
||||
// must be a mouse key
|
||||
Key KeyName
|
||||
|
||||
// If true, this binding is dispatched before ShouldHandleMouseEvent is
|
||||
// consulted, so it fires even when a popup panel is focused and the click
|
||||
// lands on a view other than that panel (which is normally swallowed). This
|
||||
// is the same early phase that hyperlink clicks are handled in; use it for
|
||||
// clicks that must stay live behind a popup, e.g. opening a diff line in the
|
||||
// editor from the main view behind the commit-message panel.
|
||||
HandleWhenPopupPanelFocused bool
|
||||
}
|
||||
|
||||
type ViewMouseBindingOpts struct {
|
||||
@@ -1246,7 +1254,7 @@ func calcScrollbarRune(
|
||||
|
||||
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
|
||||
height := v.InnerHeight()
|
||||
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
|
||||
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
|
||||
|
||||
if v.CanScrollPastBottom {
|
||||
fullHeight += height
|
||||
@@ -1467,7 +1475,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
|
||||
|
||||
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1717,13 +1725,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
if newY < 0 {
|
||||
newY = 0
|
||||
newCy = -v.oy
|
||||
} else if newY >= len(v.lines) {
|
||||
newY = len(v.lines) - 1
|
||||
} else if newY >= len(v.buf.lines) {
|
||||
newY = len(v.buf.lines) - 1
|
||||
newCy = newY - v.oy
|
||||
}
|
||||
|
||||
visibleLineWidth := 0
|
||||
for _, c := range v.lines[newY].cells {
|
||||
for _, c := range v.buf.lines[newY].cells {
|
||||
visibleLineWidth += c.width
|
||||
}
|
||||
if visibleLineWidth < newX {
|
||||
@@ -1740,6 +1748,22 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
var mouseOpts ViewMouseBindingOpts
|
||||
if IsMouseKey(ev.Key) {
|
||||
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
mouseOpts = ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
|
||||
|
||||
// Dispatch bindings that opt into firing while a popup panel is focused
|
||||
// before the gate below gets a chance to reject the click.
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matched {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if g.ShouldHandleMouseEvent != nil {
|
||||
if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) {
|
||||
// Give clients a chance to reject clicks, for example clicks in inactive views
|
||||
@@ -1789,9 +1813,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
|
||||
if IsMouseKey(ev.Key) {
|
||||
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
|
||||
matched, err := g.execMouseKeybindings(v, ev, opts)
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1855,11 +1877,12 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
|
||||
return isDoubleClick
|
||||
}
|
||||
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
|
||||
isMatch := func(binding *ViewMouseBinding) bool {
|
||||
return binding.ViewName == view.Name() &&
|
||||
ev.Key.KeyName() == binding.Key &&
|
||||
ev.Key.Mod() == binding.Modifier
|
||||
ev.Key.Mod() == binding.Modifier &&
|
||||
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
|
||||
}
|
||||
|
||||
// first pass looks for ones that match the focused view
|
||||
|
||||
@@ -202,6 +202,7 @@ const (
|
||||
|
||||
var (
|
||||
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
||||
lastMouseMod tcell.ModMask = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
@@ -370,6 +371,15 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
|
||||
newButtonPress = true
|
||||
lastMouseKey = button
|
||||
// The keyboard modifiers held at press time apply to the whole
|
||||
// gesture: the press (a click is reported on the press, so this is
|
||||
// what makes modified clicks bindable), every drag event, and the
|
||||
// release. Snapshotting them here keeps the gesture atomic: a
|
||||
// modified press can't produce drag/release events that match an
|
||||
// unmodified binding, and pressing or releasing a modifier while
|
||||
// the button is held doesn't change the gesture midway.
|
||||
lastMouseMod = tev.Modifiers()
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
switch button {
|
||||
case tcell.ButtonPrimary:
|
||||
mouseKey = MouseLeft
|
||||
@@ -395,7 +405,8 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
case tcell.ButtonMiddle:
|
||||
default:
|
||||
}
|
||||
mouseMod = ModNone
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
lastMouseMod = tcell.ModNone
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
}
|
||||
default:
|
||||
@@ -426,10 +437,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
// reaches drag bindings instead of being delivered with the
|
||||
// default MouseRelease key.
|
||||
dragState = DRAGGING
|
||||
mouseMod = ModMotion
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseKey = MouseLeft
|
||||
case DRAGGING:
|
||||
mouseMod = ModMotion
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseKey = MouseLeft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +36,55 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
}
|
||||
|
||||
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
|
||||
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, dragEvent.Type)
|
||||
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func resetMouseState() {
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
lastMouseMod = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
|
||||
+611
-150
File diff suppressed because it is too large
Load Diff
+428
-15
@@ -101,13 +101,13 @@ func TestWriteString(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal)
|
||||
for _, l := range test.existingLines {
|
||||
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
|
||||
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
|
||||
}
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.lines {
|
||||
for _, l := range v.buf.lines {
|
||||
resultingLines = append(resultingLines, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, test.expectedLines, resultingLines)
|
||||
@@ -144,19 +144,359 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
||||
|
||||
v.writeString("htt")
|
||||
// No hyperlinks are generated for incomplete URLs
|
||||
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same line makes the link complete (even
|
||||
// though we didn't see a newline yet)
|
||||
v.writeString("ps://example.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
|
||||
v.Clear()
|
||||
// Valid but incomplete URL
|
||||
v.writeString("https://exa")
|
||||
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same fixes the link
|
||||
v.writeString("mple.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
func TestSelectedLineBgColorWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bgColorWidth int
|
||||
selected func(screenX int) bool
|
||||
}{
|
||||
{
|
||||
name: "zero uses full-width selection background",
|
||||
bgColorWidth: 0,
|
||||
selected: func(_ int) bool { return true },
|
||||
},
|
||||
{
|
||||
name: "non-zero uses left-edge selection background",
|
||||
bgColorWidth: 2,
|
||||
selected: func(screenX int) bool { return screenX <= 2 },
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 4)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 3, OutputNormal) // InnerWidth=10
|
||||
v.Highlight = true
|
||||
v.SelBgColor = ColorBlue
|
||||
v.SelectedLineBgColorWidth = test.bgColorWidth
|
||||
v.writeString("0123456789\n")
|
||||
v.draw()
|
||||
|
||||
selectedBg := getTcellColor(ColorBlue, OutputNormal)
|
||||
for screenX := 1; screenX <= 10; screenX++ {
|
||||
_, style, _ := Screen.Get(screenX, 1)
|
||||
if test.selected(screenX) {
|
||||
assert.Equal(t, selectedBg, style.GetBackground(), "cell %d should show the selection background", screenX)
|
||||
} else {
|
||||
assert.Equal(t, tcell.ColorDefault, style.GetBackground(), "cell %d should keep its original background", screenX)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffLineMetadata(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// Synthetic delta-style output: each content line is prefixed with an
|
||||
// OSC 1717 sequence carrying version;type;new;old;file (old empty unless a
|
||||
// deletion), and the OSC bytes themselves must not become visible cells. The
|
||||
// final line is a header with no OSC, to prove the metadata doesn't bleed.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
osc("1;c;1;;foo.txt") + "line1",
|
||||
osc("1;d;2;2;foo.txt") + "old2",
|
||||
osc("1;a;2;;foo.txt") + "new2",
|
||||
"@@ a header line with no metadata @@",
|
||||
}, "\n"))
|
||||
|
||||
type result struct {
|
||||
payload string
|
||||
ok bool
|
||||
}
|
||||
got := make([]result, len(v.buf.lines))
|
||||
for y := range v.buf.lines {
|
||||
payload, ok := v.DiffLineMetadataInLine(y)
|
||||
got[y] = result{payload, ok}
|
||||
}
|
||||
|
||||
assert.Equal(t, []result{
|
||||
{"1;c;1;;foo.txt", true},
|
||||
{"1;d;2;2;foo.txt", true},
|
||||
{"1;a;2;;foo.txt", true},
|
||||
{"", false}, // the header line carries no metadata (no bleed)
|
||||
}, got)
|
||||
|
||||
// The OSC sequence is consumed as an escape, so the visible text is intact.
|
||||
assert.Equal(t, "line1", v.BufferLines()[0])
|
||||
assert.Equal(t, "@@ a header line with no metadata @@", v.BufferLines()[3])
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataPayloads(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
// A single-column row: one payload tags the whole line.
|
||||
osc("1;c;1;;foo.txt") + "context",
|
||||
// A side-by-side change row: the deletion tags the left half and the
|
||||
// addition replacing it tags the right half of the same rendered line.
|
||||
osc("1;d;2;2;foo.txt") + "old2 " + osc("1;a;2;;foo.txt") + "new2",
|
||||
// A header line with no metadata.
|
||||
"@@ header @@",
|
||||
}, "\n"))
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"1;c;1;;foo.txt"},
|
||||
{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"},
|
||||
nil,
|
||||
}, v.DiffLineMetadataPayloads())
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataZeroWidthRecords(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A row can carry several records back-to-back with nothing rendered in
|
||||
// between, making all but the last record's region zero-width. Those
|
||||
// payloads must survive as payloads of the row (via content-less carrier
|
||||
// cells) rather than being clobbered by the record that follows.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(strings.Join([]string{
|
||||
// difftastic's combined file+hunk banner: the f is immediately
|
||||
// followed by the h.
|
||||
osc("1;f;;;foo.txt") + osc("1;h;5;;foo.txt") + "foo.txt --- Go",
|
||||
// A modification row collapsed to a single column carries its
|
||||
// deletion and its addition back-to-back before the row's content.
|
||||
osc("1;d;5;5;foo.txt") + osc("1;a;5;;foo.txt") + "595 new content",
|
||||
// A record emitted right before the line end covers no cell either.
|
||||
"trailing" + osc("1;d;6;6;foo.txt"),
|
||||
}, "\n") + "\n")
|
||||
|
||||
assert.Equal(t, [][]string{
|
||||
{"1;f;;;foo.txt", "1;h;5;;foo.txt"},
|
||||
{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"},
|
||||
{"1;d;6;6;foo.txt"},
|
||||
}, v.DiffLineMetadataPayloads())
|
||||
|
||||
// The carrier cells are invisible: the rendered text is unchanged.
|
||||
assert.Equal(t, []string{
|
||||
"foo.txt --- Go",
|
||||
"595 new content",
|
||||
"trailing",
|
||||
}, v.BufferLines())
|
||||
|
||||
// A row's single-payload identity is its first record: the f of a banner,
|
||||
// the d of a collapsed modification row.
|
||||
payload, ok := v.DiffLineMetadataInLine(0)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "1;f;;;foo.txt", payload)
|
||||
payload, ok = v.DiffLineMetadataInLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "1;d;5;5;foo.txt", payload)
|
||||
}
|
||||
|
||||
func TestDiffLineMetadataHandshakeSwallowed(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// A metadata-aware pager emits a version-only handshake (an OSC 1717 with no
|
||||
// fields) as its first output, immediately before the diff, to announce it speaks
|
||||
// the protocol. It must be swallowed whole: no visible bytes, no phantom line, and
|
||||
// crucially it must not attach as metadata to the diff header that follows it.
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.writeString(osc("1") + strings.Join([]string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
osc("1;a;1;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
// The handshake produced no phantom line and no visible bytes.
|
||||
assert.Equal(t, []string{
|
||||
"diff --git a/foo.txt b/foo.txt",
|
||||
"added",
|
||||
}, v.BufferLines())
|
||||
|
||||
// The handshake didn't bleed onto the header line, and the real per-line metadata
|
||||
// after it still applies.
|
||||
type result struct {
|
||||
payload string
|
||||
ok bool
|
||||
}
|
||||
got := make([]result, len(v.buf.lines))
|
||||
for y := range v.buf.lines {
|
||||
payload, ok := v.DiffLineMetadataInLine(y)
|
||||
got[y] = result{payload, ok}
|
||||
}
|
||||
assert.Equal(t, []result{
|
||||
{"", false},
|
||||
{"1;a;1;;foo.txt", true},
|
||||
}, got)
|
||||
}
|
||||
|
||||
// When a re-render produces fewer view lines than the previous one,
|
||||
// refreshViewLinesIfNeeded must truncate viewLines to the new content. If it
|
||||
// didn't (it used to overwrite in place and keep the tail), a reader could map a
|
||||
// view line that no longer exists onto the wrong buffer line — and with wrapping
|
||||
// the stale entry's buffer index can still be in range of the new, shorter,
|
||||
// less-wrapped buffer, so an in-range guard alone wouldn't catch it. See
|
||||
// diff-line-metadata-notes.md §8.
|
||||
func TestBufferLineForViewLineStaleTail(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// First render: two lines that each wrap into three view lines, so the
|
||||
// buffer has 2 lines but there are 6 view lines.
|
||||
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
|
||||
// Re-render with shorter content (rewind, then overwrite from the top with
|
||||
// three short, unwrapped lines). There are now only 3 view lines.
|
||||
v.Reset()
|
||||
v.writeString("aaa\nbbb\nccc")
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
|
||||
// A real view line maps to its buffer line as usual.
|
||||
bufferLine, ok := v.BufferLineForViewLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, bufferLine)
|
||||
|
||||
// View line 4 no longer exists in the current buffer, so the mapping must
|
||||
// fail rather than land on a stale entry from the previous render.
|
||||
_, ok = v.BufferLineForViewLine(4)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
func TestOffscreenRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString("a\nb\nc")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Render new, longer content off-screen.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("w\nx\ny\nz")
|
||||
|
||||
// The displayed buffer is untouched: readers still see the previous render,
|
||||
// and the view-line→buffer-line mapping stays consistent with it.
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
bufferLine, ok := v.BufferLineForViewLine(1)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 1, bufferLine)
|
||||
|
||||
// Swapping in reveals the new content in one step.
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
|
||||
|
||||
// A further write now appends to the displayed buffer directly.
|
||||
v.writeString("\nmore")
|
||||
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// The escape restore scans the *incoming* content of a re-render as it loads,
|
||||
// before it is swapped in, so it can find the row matching a target identity and
|
||||
// decide when to swap. That means reading the off-screen buffer's loaded rows
|
||||
// (text, metadata, hyperlink) while the displayed buffer still shows the old
|
||||
// render. See View.offscreen / OffscreenDiffLineContents.
|
||||
func TestOffscreenDiffLineContents(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
// No off-screen render in progress: nothing to scan.
|
||||
assert.Nil(t, v.OffscreenDiffLineContents())
|
||||
|
||||
osc := func(payload string) string { return "\x1b]1717;" + payload + "\x1b\\" }
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Join([]string{
|
||||
osc("1;c;1;;foo.txt") + "context",
|
||||
osc("1;a;2;;foo.txt") + "added",
|
||||
}, "\n"))
|
||||
|
||||
contents := v.OffscreenDiffLineContents()
|
||||
assert.Equal(t, []DiffLineContent{
|
||||
{Text: "context", Metadata: "1;c;1;;foo.txt"},
|
||||
{Text: "added", Metadata: "1;a;2;;foo.txt"},
|
||||
}, contents)
|
||||
|
||||
// The displayed buffer is still empty; the scan reads the off-screen render.
|
||||
assert.Empty(t, v.BufferLines())
|
||||
}
|
||||
|
||||
// The escape restore matches a target identity against a buffer line, then needs
|
||||
// the view line that renders it to scroll there and select it. ViewLineForBufferLine
|
||||
// is that inverse mapping, and it must point at the *first* of the (wrapped) view
|
||||
// lines a buffer line spans.
|
||||
func TestViewLineForBufferLine(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Buffer line 0 is short (one view line); buffer line 1 wraps into three view
|
||||
// lines (view lines 1, 2, 3); buffer line 2 is short again (view line 4).
|
||||
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
|
||||
|
||||
for bufferLine, wantViewLine := range map[int]int{0: 0, 1: 1, 2: 4} {
|
||||
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, wantViewLine, viewLine)
|
||||
}
|
||||
|
||||
_, ok := v.ViewLineForBufferLine(3)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
// snap back as the rest streams in. See View.scrollbarHeightFloor.
|
||||
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
// Initial render: 100 lines, scrolled well down.
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.SetOrigin(0, 80)
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A re-render begins while the previous render is still shown: hold the
|
||||
// scrollbar height at the current value.
|
||||
v.FreezeScrollbarHeight()
|
||||
|
||||
// The off-screen render swaps in only a screenful at its first paint.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Repeat("y\n", 30))
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
// The displayed buffer is now short, but the scrollbar height stays held, so
|
||||
// the thumb keeps its position instead of jumping.
|
||||
assert.Equal(t, 30, v.ViewLinesHeight())
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// The rest of the content streams in.
|
||||
v.writeString(strings.Repeat("y\n", 70))
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// Once the load ends, the scrollbar tracks the real content directly again.
|
||||
v.UnfreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
|
||||
// before it reaches its end, the held scrollbar height must be released, so the
|
||||
// scrollbar reflects the new content rather than the abandoned load's height.
|
||||
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.FreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A synchronous render replaces the content before the (notional) load ends.
|
||||
v.SetContent("just a few\nshort lines\nhere")
|
||||
assert.Equal(t, 3, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
@@ -233,7 +573,7 @@ func TestContainsColoredText(t *testing.T) {
|
||||
for j, cells := range test.lines {
|
||||
lines[j] = lineType{cells: cells}
|
||||
}
|
||||
v := &View{lines: lines}
|
||||
v := &View{buf: &viewBuffer{lines: lines}}
|
||||
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
|
||||
}
|
||||
}
|
||||
@@ -248,8 +588,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
|
||||
// "a", then "skip to row 3" (i.e. one blank row), then "b".
|
||||
v.writeString("a\r\n\x1b[3;1Hb\r\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
@@ -269,8 +609,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
||||
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
|
||||
v.writeString("c\x1b[5;1Hd\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
@@ -292,8 +632,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
|
||||
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
|
||||
v.writeString("a\x1b[5X\x1b[5Cb\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
@@ -312,8 +652,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
|
||||
v.writeString("abcdefghij\n")
|
||||
v.writeString("\x1b[4;1Hxyz\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
@@ -664,3 +1004,76 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
||||
"trailing cell at (%d, 2) should have green bg", x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInclusionGutter verifies the on-demand inclusion gutter reserves a
|
||||
// left-hand column, draws the marker glyph on marked lines only, and shifts the
|
||||
// content right past it.
|
||||
func TestInclusionGutter(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// InnerWidth=10; the frame inset of 1 places 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 2 columns wide (marker + separator); mark the middle line.
|
||||
v.SetInclusionGutter(true, []bool{false, true, false})
|
||||
v.draw()
|
||||
|
||||
// The marker appears at the gutter's first column (view x=0 → screen x=1) on
|
||||
// the marked line only.
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "marked line shows the gutter marker")
|
||||
chr, _, _ = Screen.Get(1, 3)
|
||||
assert.Equal(t, " ", chr, "unmarked line has no gutter marker")
|
||||
|
||||
// The content is shifted right past the 2-column gutter (view x=2 → screen x=3).
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "a", chr, "content is shifted past the gutter")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "b", chr)
|
||||
chr, _, _ = Screen.Get(3, 3)
|
||||
assert.Equal(t, "c", chr)
|
||||
|
||||
// Hiding the gutter again returns the content flush left (view x=0 → screen x=1).
|
||||
v.SetInclusionGutter(false, nil)
|
||||
v.draw()
|
||||
chr, _, _ = Screen.Get(1, 1)
|
||||
assert.Equal(t, "a", chr, "content is flush left with no gutter")
|
||||
}
|
||||
|
||||
// TestInclusionGutterMarkerOnEverySegment verifies that a marked buffer line that
|
||||
// wraps shows the marker on every wrapped segment, and that the gutter narrows the
|
||||
// content wrap width.
|
||||
func TestInclusionGutterMarkerOnEverySegment(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth=10
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
// 10 cells; with a 2-column gutter the content wrap width is 8, so this wraps
|
||||
// to "01234567" / "89".
|
||||
v.writeString("0123456789\n")
|
||||
v.SetInclusionGutter(true, []bool{true})
|
||||
v.draw()
|
||||
|
||||
// First segment: marker present, content starts at screen x=3 and the eighth
|
||||
// content cell ("7") sits at the right edge (screen x=10).
|
||||
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)
|
||||
|
||||
// Continuation segment: marker too, content resumes at screen x=3.
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "continuation segment also shows the marker")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "8", chr)
|
||||
}
|
||||
|
||||
@@ -373,3 +373,15 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
|
||||
|
||||
panic("context not in stack")
|
||||
}
|
||||
|
||||
// IsInStack reports whether the given context is somewhere in the context stack.
|
||||
// Unlike NextInStack it never panics, so it's safe to call before dereferencing
|
||||
// the stack from a caller that might run with the context off-stack.
|
||||
func (self *ContextMgr) IsInStack(c types.Context) bool {
|
||||
self.RLock()
|
||||
defer self.RUnlock()
|
||||
|
||||
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
|
||||
return other.GetKey() == c.GetKey()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ type BaseContext struct {
|
||||
windowName string
|
||||
onGetOptionsMap func() map[string]string
|
||||
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onClickFocusedMainViewFn onClickFocusedMainViewFn
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
focusedMainViewActions types.FocusedMainViewActions
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
@@ -34,9 +34,8 @@ type BaseContext struct {
|
||||
}
|
||||
|
||||
type (
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
)
|
||||
|
||||
var _ types.IBaseContext = &BaseContext{}
|
||||
@@ -145,7 +144,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.onClickFocusedMainViewFn = nil
|
||||
self.focusedMainViewActions = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -167,12 +166,12 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
|
||||
}
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
|
||||
if fn != nil {
|
||||
if self.onClickFocusedMainViewFn != nil {
|
||||
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
|
||||
func (self *BaseContext) AddFocusedMainViewActions(actions types.FocusedMainViewActions) {
|
||||
if actions != nil {
|
||||
if self.focusedMainViewActions != nil {
|
||||
panic("only one controller is allowed to set the focused main view actions")
|
||||
}
|
||||
self.onClickFocusedMainViewFn = fn
|
||||
self.focusedMainViewActions = actions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +183,8 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
|
||||
return self.onClickFn
|
||||
}
|
||||
|
||||
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
|
||||
return self.onClickFocusedMainViewFn
|
||||
func (self *BaseContext) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self.focusedMainViewActions
|
||||
}
|
||||
|
||||
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
|
||||
|
||||
@@ -19,11 +19,16 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
@@ -80,10 +85,17 @@ 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 derives the diff endpoints for a ref (or a range of refs): a range
|
||||
// diffs its parent-of-from against to, a single ref its parent against itself. It's
|
||||
// shared by the commit files context and by patch building straight from the commits /
|
||||
// sub-commits / stash main views, which build a patch for the panel's selected ref.
|
||||
func FromAndToForDiff(ref models.Ref, refRange *types.RefRange) (string, string) {
|
||||
if refRange != nil {
|
||||
return refRange.From.ParentRefName(), refRange.To.RefName()
|
||||
}
|
||||
ref := self.GetRef()
|
||||
return ref.ParentRefName(), ref.RefName()
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -8,10 +8,61 @@ import (
|
||||
type MainContext struct {
|
||||
*SimpleContext
|
||||
*SearchTrait
|
||||
|
||||
diffSelect DiffSelectState
|
||||
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so a drag
|
||||
// that follows can anchor its range there. The click can show a whole hunk (a range
|
||||
// with a different anchor), so the clicked line can't be read back from the view.
|
||||
dragAnchorViewLine int
|
||||
}
|
||||
|
||||
var _ types.ISearchableContext = (*MainContext)(nil)
|
||||
|
||||
// DiffSelectMode is how the focused main view's diff selection extends from the
|
||||
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
|
||||
// around the cursor.
|
||||
type DiffSelectMode int
|
||||
|
||||
const (
|
||||
DiffSelectModeLine DiffSelectMode = iota
|
||||
DiffSelectModeRange
|
||||
DiffSelectModeHunk
|
||||
)
|
||||
|
||||
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
|
||||
// selected line and the range anchor themselves live in the gocui view (its
|
||||
// cursor and rangeSelectStartY, i.e. native range select); only the mode lives
|
||||
// here. It's on the context, not the controller, so that the main view controller
|
||||
// (which drives the selection), the focus controller (which resets it on focus),
|
||||
// and togglePanel (which sets it on the other pane) can all reach it via the
|
||||
// context they already hold.
|
||||
type DiffSelectState struct {
|
||||
Mode DiffSelectMode
|
||||
// When a range is sticky, moving the cursor without holding shift extends the
|
||||
// range; otherwise it collapses the range back to a single line.
|
||||
RangeIsSticky bool
|
||||
// Whether the user turned on hunk mode explicitly, as opposed to it being the
|
||||
// configured default; this decides whether escape leaves hunk mode.
|
||||
UserEnabledHunkMode bool
|
||||
}
|
||||
|
||||
// DiffSelectState returns the focused main view's selection mode state, for the
|
||||
// controllers to read and mutate directly.
|
||||
func (self *MainContext) DiffSelectState() *DiffSelectState {
|
||||
return &self.diffSelect
|
||||
}
|
||||
|
||||
// SetDragAnchorViewLine records the view line a mouse-down landed on, so a drag that
|
||||
// follows can anchor its range there (see dragAnchorViewLine).
|
||||
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
|
||||
self.dragAnchorViewLine = viewLine
|
||||
}
|
||||
|
||||
// DragAnchorViewLine returns the view line the last mouse-down landed on.
|
||||
func (self *MainContext) DragAnchorViewLine() int {
|
||||
return self.dragAnchorViewLine
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
windowName string,
|
||||
|
||||
@@ -20,6 +20,11 @@ type PatchExplorerContext struct {
|
||||
// true if we're inside the OnSelectItem callback; in that case we don't want to update the
|
||||
// search result index.
|
||||
inOnSelectItemCallback bool
|
||||
|
||||
// Set when this patch explorer was entered from a focused main view, so that
|
||||
// escaping returns there; nil for the normal flow. See
|
||||
// types.FocusedMainViewSnapshot.
|
||||
focusedMainViewSnapshot *types.FocusedMainViewSnapshot
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -60,6 +65,14 @@ func NewPatchExplorerContext(
|
||||
|
||||
func (self *PatchExplorerContext) IsPatchExplorerContext() {}
|
||||
|
||||
func (self *PatchExplorerContext) GetFocusedMainViewSnapshot() *types.FocusedMainViewSnapshot {
|
||||
return self.focusedMainViewSnapshot
|
||||
}
|
||||
|
||||
func (self *PatchExplorerContext) SetFocusedMainViewSnapshot(snapshot *types.FocusedMainViewSnapshot) {
|
||||
self.focusedMainViewSnapshot = snapshot
|
||||
}
|
||||
|
||||
func (self *PatchExplorerContext) GetState() *patch_exploring.State {
|
||||
return self.state
|
||||
}
|
||||
|
||||
@@ -14,10 +14,17 @@ type ReflogCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
|
||||
)
|
||||
|
||||
// Reflog shows a commit's diff but has no primary action (building a patch from it is
|
||||
// a deferred gap), so the focused main view has a selection but space does nothing.
|
||||
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
viewModel := NewFilteredListViewModel(
|
||||
func() []*models.Commit { return c.Model().FilteredReflogCommits },
|
||||
|
||||
@@ -12,10 +12,15 @@ type StashContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.DiffMainViewContext = (*StashContext)(nil)
|
||||
)
|
||||
|
||||
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewStashContext(
|
||||
c *ContextCommon,
|
||||
) *StashContext {
|
||||
|
||||
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
|
||||
)
|
||||
|
||||
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypeStaging
|
||||
}
|
||||
|
||||
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
viewModel := filetree.NewFileTreeViewModel(
|
||||
func() []*models.File { return c.Model().Files },
|
||||
|
||||
@@ -52,8 +52,9 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
|
||||
gpgHelper := helpers.NewGpgHelper(helperCommon)
|
||||
viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts)
|
||||
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon)
|
||||
stagingHelper := helpers.NewStagingHelper(helperCommon)
|
||||
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
|
||||
stagingHelper := helpers.NewStagingHelper(helperCommon, windowHelper)
|
||||
patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon, stagingHelper)
|
||||
mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon)
|
||||
searchHelper := helpers.NewSearchHelper(helperCommon)
|
||||
|
||||
@@ -73,7 +74,6 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
rebaseHelper,
|
||||
)
|
||||
bisectHelper := helpers.NewBisectHelper(helperCommon)
|
||||
windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper)
|
||||
modeHelper := helpers.NewModeHelper(
|
||||
helperCommon,
|
||||
diffHelper,
|
||||
@@ -108,6 +108,7 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
FixupHelper: helpers.NewFixupHelper(helperCommon),
|
||||
Commits: commitsHelper,
|
||||
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
|
||||
CommitFiles: helpers.NewCommitFilesHelper(helperCommon, patchBuildingHelper),
|
||||
Snake: helpers.NewSnakeHelper(helperCommon),
|
||||
Diff: diffHelper,
|
||||
Repos: reposHelper,
|
||||
@@ -173,7 +174,6 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
contextLinesController := controllers.NewContextLinesController(common)
|
||||
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
|
||||
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
|
||||
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
|
||||
|
||||
branchesController := controllers.NewBranchesController(common)
|
||||
gitFlowController := controllers.NewGitFlowController(common)
|
||||
@@ -310,13 +310,11 @@ func (gui *Gui) resetHelpersAndControllers() {
|
||||
controllers.AttachControllers(gui.State.Contexts.Normal,
|
||||
mainViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
|
||||
secondaryViewController,
|
||||
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
|
||||
)
|
||||
|
||||
controllers.AttachControllers(gui.State.Contexts.Files,
|
||||
|
||||
@@ -8,7 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
|
||||
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
|
||||
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
|
||||
context.AddOnClickFn(controller.GetOnClick())
|
||||
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
|
||||
context.AddFocusedMainViewActions(controller.GetFocusedMainViewActions())
|
||||
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
|
||||
context.AddOnFocusFn(controller.GetOnFocus())
|
||||
context.AddOnFocusLostFn(controller.GetOnFocusLost())
|
||||
|
||||
@@ -19,7 +19,7 @@ func (self *baseController) GetOnDoubleClick() func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
func (self *baseController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -176,8 +176,19 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
|
||||
paths := self.pathsForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false)
|
||||
task := types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, false, renderRaw)
|
||||
task := types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
|
||||
|
||||
// Keep the inclusion gutter in step with the content as this diff (re-)renders.
|
||||
// It's a no-op unless the main view is focused and a patch is being built (see
|
||||
// RefreshInclusionGutter); a patch toggle re-renders this same diff, so the marks
|
||||
// recomputed over the current content stay valid through the swap.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a commit rewrite. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
@@ -195,7 +206,7 @@ func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMess
|
||||
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, true, false)
|
||||
diff, err := cmdObj.RunWithOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -492,7 +503,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
})
|
||||
}
|
||||
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
@@ -503,7 +514,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -518,68 +529,8 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode)
|
||||
return self.toggleForPatch([]*filetree.CommitFileNode{root})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) startPatchBuilder() error {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
canRebase := commitFilesContext.GetCanRebase()
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
|
||||
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
from, to := commitFilesContext.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
return from, to, reverse
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error {
|
||||
if node.File == nil {
|
||||
return self.handleToggleCommitFileDirCollapsed(node)
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
from, to, reverse := self.currentFromToReverseForPatchBuilding()
|
||||
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
Prompt: self.c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
|
||||
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error {
|
||||
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
|
||||
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
return nil
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
|
||||
}
|
||||
|
||||
// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
|
||||
@@ -606,14 +557,74 @@ func (self *CommitFilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
func (self *CommitFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
||||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
|
||||
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.getSelectedItem()
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath = "./" + relativePath
|
||||
self.context().CommitFileTreeViewModel.ExpandToPath(relativePath)
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath)
|
||||
if ok {
|
||||
self.context().SetSelectedLineIdx(idx)
|
||||
self.context().GetViewTrait().FocusPoint(
|
||||
self.context().ModelIndexToViewIndex(idx), false)
|
||||
node = self.context().GetSelected()
|
||||
}
|
||||
}
|
||||
|
||||
// Entered from the focused main view, so escaping returns there.
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
|
||||
// PrimaryAction toggles the selected diff line(s) into or out of the custom patch when
|
||||
// space is pressed in the focused main view of a commit's files. The per-file diff's
|
||||
// patch target comes from the commit files context. It refreshes normally afterwards so
|
||||
// the file's patch-status indicator in the browser updates along with the secondary patch
|
||||
// view (the commits / sub-commits / stash panels, which build from the whole-commit diff,
|
||||
// have no such indicator and refresh more cheaply).
|
||||
func (self *CommitFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
canRebase := self.context().GetCanRebase()
|
||||
return primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
|
||||
from, to, reverse, canRebase,
|
||||
func() {
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}})
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardSelection removes the selected diff line(s) from the commit the focused main
|
||||
// view shows the files of, via a rebase. Same target derivation as the patch toggle.
|
||||
func (self *CommitFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding()
|
||||
canRebase := self.context().GetCanRebase()
|
||||
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
||||
return discardFromCommitDisabledReason(self.c, mainViewName, self.context().GetCanRebase())
|
||||
}
|
||||
|
||||
// pathsForDiff returns the file paths to use for a diff command. When a text
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
@@ -51,10 +50,6 @@ func (self *ContextLinesController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) Increase() error {
|
||||
if err := self.checkCanChangeContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize < math.MaxUint64 {
|
||||
self.c.UserConfig().Git.DiffContextSize++
|
||||
}
|
||||
@@ -62,10 +57,6 @@ func (self *ContextLinesController) Increase() error {
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) Decrease() error {
|
||||
if err := self.checkCanChangeContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize > 0 {
|
||||
self.c.UserConfig().Git.DiffContextSize--
|
||||
}
|
||||
@@ -83,15 +74,13 @@ func (self *ContextLinesController) applyChange() error {
|
||||
case context.STAGING_MAIN_CONTEXT_KEY, context.STAGING_SECONDARY_CONTEXT_KEY:
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STAGING}})
|
||||
default:
|
||||
// The new context size re-renders the diff with a different git command, which
|
||||
// would otherwise reset the main view to the top. Preserve where it was
|
||||
// scrolled (and its selection, if the focused main view is showing one)
|
||||
// instead. This covers both the focused main view and the side panels, since
|
||||
// they all render their diff into the same "main" view.
|
||||
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
currentContext.HandleRenderToMain()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ContextLinesController) checkCanChangeContext() error {
|
||||
if self.c.Git().Patch.PatchBuilder.Active() {
|
||||
return errors.New(self.c.Tr.CantChangeContextSizeError)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
@@ -249,6 +250,11 @@ func (self *FilesController) GetOnRenderToMain() func() {
|
||||
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
||||
node := self.context().GetSelected()
|
||||
|
||||
// Default the focused-main-view selection off; the real-diff branch below
|
||||
// turns it back on. Every other outcome (no file, merge conflict) renders
|
||||
// non-diff content with nothing to select.
|
||||
updateFocusedMainViewSelectionVisibility(self.c, false, false)
|
||||
|
||||
if node == nil {
|
||||
self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles))
|
||||
return
|
||||
@@ -347,7 +353,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()}, false)
|
||||
prefix := message + "\n\n"
|
||||
if node.File.ShortStatus == "DU" {
|
||||
prefix += self.c.Tr.MergeConflictIncomingDiff
|
||||
@@ -365,11 +371,15 @@ 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()
|
||||
split, mainShowsStaged := self.diffSplitState(node)
|
||||
|
||||
// When the focused main view needs to act on a diff the configured pager
|
||||
// can't resolve, render it raw (no pager) so it's stageable; browsing keeps
|
||||
// the pretty pager output. See StagingHelper.DiffMainViewShouldRenderRaw.
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
|
||||
pathOverrides := self.pathOverridesForDiff(node)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, renderRaw, pathOverrides)
|
||||
title := self.c.Tr.UnstagedChanges
|
||||
if mainShowsStaged {
|
||||
title = self.c.Tr.StagedChanges
|
||||
@@ -377,14 +387,14 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts := types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
if split {
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, renderRaw, pathOverrides)
|
||||
|
||||
title := self.c.Tr.StagedChanges
|
||||
if mainShowsStaged {
|
||||
@@ -394,10 +404,15 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
|
||||
refreshOpts.Secondary = &types.ViewUpdateOpts{
|
||||
Title: title,
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: types.NewRunPtyTask(cmdObj.GetCmd()),
|
||||
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
||||
}
|
||||
}
|
||||
|
||||
// A real diff is being shown, so the focused main view has something to select:
|
||||
// restore the selection on whichever pane is focused (the secondary only when
|
||||
// the diff is split across both).
|
||||
updateFocusedMainViewSelectionVisibility(self.c, true, split)
|
||||
|
||||
self.c.RenderToMainViews(refreshOpts)
|
||||
}
|
||||
|
||||
@@ -407,14 +422,256 @@ func (self *FilesController) GetOnDoubleClick() func() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
node := self.getSelectedItem()
|
||||
if node != nil && node.File != nil {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
|
||||
}
|
||||
func (self *FilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *FilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
||||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context())
|
||||
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath = "./" + relativePath
|
||||
self.context().FileTreeViewModel.ExpandToPath(relativePath)
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
|
||||
idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath)
|
||||
if ok {
|
||||
self.context().SetSelectedLineIdx(idx)
|
||||
self.context().GetViewTrait().FocusPoint(
|
||||
self.context().ModelIndexToViewIndex(idx), false)
|
||||
}
|
||||
}
|
||||
|
||||
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
|
||||
// diffSplitState reports, for the given file node, how the focused main view lays
|
||||
// out its diff: whether it's split into unstaged (Normal) and staged
|
||||
// (NormalSecondary) halves, and — when not split — whether the single Normal view
|
||||
// shows the staged diff (which happens when the file has only staged changes).
|
||||
// GetOnRenderToMain and PrimaryAction share this so the staging direction
|
||||
// can't drift from what's on screen.
|
||||
func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool, mainShowsStaged bool) {
|
||||
split = self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
|
||||
mainShowsStaged = !split && node.GetHasStagedChanges()
|
||||
return split, mainShowsStaged
|
||||
}
|
||||
|
||||
// PrimaryAction stages (or unstages) the selected diff line(s) when space is pressed in
|
||||
// the focused main view of the working-tree files panel.
|
||||
func (self *FilesController) PrimaryAction(mainViewName string, 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(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The whole diff shown in the main view is on one side; space unstages when that's
|
||||
// the staged side (reverse), otherwise it stages. Either way it applies to the index.
|
||||
return self.applyDiffLineSelection(mainViewName, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true})
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason: discarding from the working tree is always available
|
||||
// (a zero-context diff is reported as an error from DiscardSelection itself, matching the
|
||||
// staging view).
|
||||
// DiscardSelectionDisabledReason ignores which pane the action came from: discarding from
|
||||
// the working tree is meaningful in both the unstaged and staged (secondary) panes (the
|
||||
// staged side just unstages), unlike the commit panels' custom-patch preview.
|
||||
func (self *FilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) DiscardSelection(mainViewName string, 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(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discarding always reverses the change. On the staged side that's just unstaging
|
||||
// (apply reverse to the index) — same as space there — so it follows the identical
|
||||
// path and isn't destructive, hence no confirm. On the unstaged side it removes the
|
||||
// change from the working tree (reverse, not cached), which is destructive.
|
||||
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(mainViewName, firstLineIdx, infos, onStagedSide,
|
||||
git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// diffLineSelection resolves the change lines the user has selected in the focused main
|
||||
// view and which side of the diff they're on. onStagedSide is true when the diff shown
|
||||
// is the staged side — the secondary half of a split, or the main half of an only-staged
|
||||
// file; it's the same for every file in a multi-file (directory) diff. ok is false when
|
||||
// there's no file or the selection covers no change lines (the caller does nothing).
|
||||
func (self *FilesController) diffLineSelection(mainViewName string, firstLineIdx int, lastLineIdx int) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) {
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
infos = self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if len(infos) == 0 {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
_, mainShowsStaged := self.diffSplitState(node)
|
||||
onStagedSide = mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName()
|
||||
return infos, onStagedSide, true
|
||||
}
|
||||
|
||||
// applyDiffLineSelection applies the selected change lines (grouped per file for a
|
||||
// multi-file diff) with opts, then re-establishes the selection and focus the way the
|
||||
// staging view does — shared by the primary action (stage/unstage) and discard, since
|
||||
// discarding on the staged side is just unstaging and must follow the identical path.
|
||||
// onStagedSide names which diff each file's patch is read from and drives the focus-
|
||||
// follow; opts says how to apply it.
|
||||
func (self *FilesController) applyDiffLineSelection(mainViewName string, firstLineIdx int, infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts) error {
|
||||
// A directory diff spans several files; group the selected change lines by
|
||||
// file and apply one patch per file.
|
||||
infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path })
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.ApplyPatch)
|
||||
for path, fileInfos := range infosByFile {
|
||||
file := self.fileForDiffLinePath(path)
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
if err := self.applyDiffLines(file, fileInfos, onStagedSide, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
|
||||
|
||||
// Focus follows the side that was acted on. The unstaged side stays in the main half
|
||||
// (which always holds the unstaged side, or the staged side once the file has only
|
||||
// staged changes). The staged side lives in the secondary half once the file is split
|
||||
// into staged + unstaged, and moves back to the main half when the staged side empties
|
||||
// and the split collapses. The model is up to date now (Refresh above is synchronous),
|
||||
// so the post-op split is read from the freshly selected node.
|
||||
focusViewName := self.c.Contexts().Normal.GetViewName()
|
||||
if onStagedSide {
|
||||
if node := self.context().GetSelected(); node != nil {
|
||||
if split, _ := self.diffSplitState(node); split {
|
||||
focusViewName = self.c.Contexts().NormalSecondary.GetViewName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Refresh above queued the main-view re-render; re-establish the selection at its
|
||||
// change-line ordinal once that render lands, and focus the pane now holding the
|
||||
// acted-on side.
|
||||
revealSelectionAfterPrimaryAction(self.c, mainViewName, focusViewName, firstLineIdx, 0)
|
||||
if focusViewName != mainViewName {
|
||||
self.c.Context().Push(mainContextForViewName(self.c, focusViewName), types.OnFocusOpts{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileForDiffLinePath maps a diff line's absolute file path (as carried by the
|
||||
// diff-line metadata) to the working-tree file it belongs to, or nil if it isn't a
|
||||
// tracked working-tree file.
|
||||
func (self *FilesController) 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, to the diff lines identified by infos (a single line, a
|
||||
// range, or a hunk) — all belonging to file — a patch built from the file's staged or
|
||||
// unstaged diff (sourceCached: the side the selection was made on) and applied per opts:
|
||||
// - stage: read unstaged, apply forward to the index (sourceCached=false, Reverse=false, Cached=true)
|
||||
// - unstage: read staged, apply reverse to the index (sourceCached=true, Reverse=true, Cached=true)
|
||||
// - discard: read the shown side, apply reverse — not cached on the unstaged side
|
||||
// removes the change from the working tree, cached on the staged side just unstages it.
|
||||
//
|
||||
// The read side and the apply direction are independent (they only coincide for
|
||||
// staging/unstaging), so they're passed separately: the patch lines are matched in the
|
||||
// diff actually shown, then Transform reverses them to match opts. A selection covering
|
||||
// no change lines yields an empty patch and is a no-op. The caller logs the action and
|
||||
// refreshes, once, around the (possibly several) files it touches.
|
||||
//
|
||||
// Each selected change line is keyed by its (file line number, deletion?) identity,
|
||||
// and the freshly parsed patch is scanned for the body lines matching those
|
||||
// identities. Scanning the patch and matching identities — rather than looking each
|
||||
// line number up with PatchLineForLineNumber — is what makes a modified line work: a
|
||||
// deletion and the addition replacing it share a position but have distinct
|
||||
// identities, and the line-number lookup can't tell an addition at the start of a
|
||||
// hunk from the deletion above it.
|
||||
func (self *FilesController) applyDiffLines(file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts) error {
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, sourceCached))
|
||||
|
||||
type changeLineKey struct {
|
||||
lineNumber int
|
||||
isDeletion bool
|
||||
}
|
||||
selected := make(map[changeLineKey]bool, len(infos))
|
||||
for _, info := range infos {
|
||||
lineNumber, isDeletion := info.PatchSelectLine()
|
||||
selected[changeLineKey{lineNumber, isDeletion}] = true
|
||||
}
|
||||
|
||||
var patchLineIndices []int
|
||||
for idx, line := range parsedPatch.Lines() {
|
||||
var key changeLineKey
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
key = changeLineKey{parsedPatch.LineNumberOfLine(idx), false}
|
||||
case line.IsDeletion():
|
||||
key = changeLineKey{parsedPatch.OldLineNumberOfLine(idx), true}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if selected[key] {
|
||||
patchLineIndices = append(patchLineIndices, idx)
|
||||
}
|
||||
}
|
||||
|
||||
patchToApply := parsedPatch.
|
||||
Transform(patch.TransformOpts{
|
||||
Reverse: opts.Reverse,
|
||||
IncludedLineIndices: patchLineIndices,
|
||||
FileNameOverride: file.GetPath(),
|
||||
}).
|
||||
FormatPlain()
|
||||
if patchToApply == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.c.Git().Patch.ApplyPatch(patchToApply, opts)
|
||||
}
|
||||
|
||||
// if we are dealing with a status for which there is no key in this map,
|
||||
@@ -701,7 +958,7 @@ func (self *FilesController) getSelectedFile() *models.File {
|
||||
}
|
||||
|
||||
func (self *FilesController) enter() error {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
|
||||
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1})
|
||||
}
|
||||
|
||||
func (self *FilesController) collapseAll() error {
|
||||
@@ -720,7 +977,11 @@ func (self *FilesController) expandAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
||||
// focusedMainViewSnapshot records the focused main view to return to when
|
||||
// escaping the staging view, for the case where we're entering it straight from
|
||||
// there; it's nil for the normal flow that goes through the files panel. See
|
||||
// types.FocusedMainViewSnapshot.
|
||||
func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
|
||||
node := self.context().GetSelected()
|
||||
if node == nil {
|
||||
return nil
|
||||
@@ -747,6 +1008,14 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error {
|
||||
}
|
||||
|
||||
context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging)
|
||||
// Record the focused-main-view return on *both* staging halves, not just the
|
||||
// one we're entering: staging the last unstaged hunk (or tabbing) moves the
|
||||
// selection to the other half, and escaping from there must still know to
|
||||
// return to the focused main view rather than falling back to the files panel.
|
||||
// Set on every entry (nil for a normal entry through the files panel) so a
|
||||
// snapshot can't leak from a previous main-view entry into a subsequent normal one.
|
||||
self.c.Contexts().Staging.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
||||
self.c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
||||
self.c.Context().Push(context, opts)
|
||||
self.c.Helpers().PatchBuilding.ShowHunkStagingHint()
|
||||
|
||||
@@ -1530,7 +1799,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro
|
||||
}
|
||||
|
||||
func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
||||
return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y})
|
||||
}
|
||||
|
||||
func (self *FilesController) fetch() error {
|
||||
|
||||
@@ -185,11 +185,21 @@ func (self *GlobalController) cycleDiffRenderersBackward() error {
|
||||
// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer
|
||||
// takes effect, and shows a toast naming it.
|
||||
func (self *GlobalController) onDiffRenderersChanged() {
|
||||
self.applyCurrentPagerSelectionStyle()
|
||||
|
||||
currentSide := self.c.Context().CurrentSide()
|
||||
currentKey := self.c.Context().Current().GetKey()
|
||||
if currentSide.GetKey() == currentKey ||
|
||||
currentKey == context.NORMAL_MAIN_CONTEXT_KEY ||
|
||||
currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY {
|
||||
// Switching pagers re-renders the diff. Keep it anchored on the same patch
|
||||
// line across the switch, restoring by patch identity rather than raw line
|
||||
// number: two pagers can structure the same diff very differently (side-by-
|
||||
// side vs inline), so the same screen line means something different after.
|
||||
// Without this a plain pager swap keeps the old origin (the git command is
|
||||
// unchanged) but now pointing at the wrong content, while an entry with its
|
||||
// own externalDiffCommand changes the git command and so resets to the top.
|
||||
self.c.Helpers().Staging.PreserveDiffPositionOnRerender(self.c.Contexts().Normal.GetView())
|
||||
currentSide.HandleRenderToMain()
|
||||
}
|
||||
|
||||
@@ -203,6 +213,15 @@ func (self *GlobalController) onDiffRenderersChanged() {
|
||||
}))
|
||||
}
|
||||
|
||||
func (self *GlobalController) applyCurrentPagerSelectionStyle() {
|
||||
bgColorWidth := 0
|
||||
if self.c.State().GetDiffRendererConfigManager().GetNarrowSelectionHighlight() {
|
||||
bgColorWidth = 2
|
||||
}
|
||||
self.c.Contexts().Normal.GetView().SelectedLineBgColorWidth = bgColorWidth
|
||||
self.c.Contexts().NormalSecondary.GetView().SelectedLineBgColorWidth = bgColorWidth
|
||||
}
|
||||
|
||||
func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
|
||||
_, total := self.c.State().GetDiffRendererConfigManager().CurrentDiffRendererIndex()
|
||||
if total <= 1 {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type CommitFilesHelper struct {
|
||||
c *HelperCommon
|
||||
|
||||
patchBuildingHelper *PatchBuildingHelper
|
||||
}
|
||||
|
||||
func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHelper) *CommitFilesHelper {
|
||||
return &CommitFilesHelper{
|
||||
c: c,
|
||||
patchBuildingHelper: patchBuildingHelper,
|
||||
}
|
||||
}
|
||||
|
||||
// focusedMainViewSnapshot records the focused main view to return to when
|
||||
// escaping the patch builder, for the case where we're entering it straight from
|
||||
// there; it's nil for the normal flow that goes through the commit files panel.
|
||||
// See types.FocusedMainViewSnapshot.
|
||||
func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error {
|
||||
if node.File == nil {
|
||||
self.handleToggleCommitFileDirCollapsed(node)
|
||||
return nil
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
from, to, reverse := self.CurrentFromToReverseForPatchBuilding()
|
||||
mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
Prompt: self.c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.StartPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set on every entry (so it can't leak from a previous main-view
|
||||
// entry into a subsequent normal one), right as we push the patch
|
||||
// builder.
|
||||
self.c.Contexts().CustomPatchBuilder.SetFocusedMainViewSnapshot(focusedMainViewSnapshot)
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
|
||||
self.patchBuildingHelper.ShowHunkStagingHint()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *CommitFilesHelper) context() *context.CommitFilesContext {
|
||||
return self.c.Contexts().CommitFiles
|
||||
}
|
||||
|
||||
func (self *CommitFilesHelper) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) {
|
||||
self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath())
|
||||
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
}
|
||||
|
||||
func (self *CommitFilesHelper) StartPatchBuilder() error {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
canRebase := commitFilesContext.GetCanRebase()
|
||||
from, to, reverse := self.CurrentFromToReverseForPatchBuilding()
|
||||
|
||||
self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CommitFilesHelper) CurrentFromToReverseForPatchBuilding() (string, string, bool) {
|
||||
commitFilesContext := self.context()
|
||||
|
||||
from, to := commitFilesContext.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
return from, to, reverse
|
||||
}
|
||||
@@ -52,7 +52,11 @@ func (self *DiffHelper) DiffArgs() []string {
|
||||
// and the refRange for a range selection. If the refRange is nil (meaning that
|
||||
// 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 {
|
||||
// GetUpdateTaskForRenderingCommitsDiff builds the task for showing a commit's (or a
|
||||
// commit range's) diff in the main view. renderRaw bypasses the pager for the focused
|
||||
// main view's raw-diff fallback (see StagingHelper.DiffMainViewShouldRenderRaw); the
|
||||
// calling panel computes it, since this helper can't reach the staging helper.
|
||||
func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange, renderRaw bool) types.UpdateTask {
|
||||
if refRange != nil {
|
||||
from, to := refRange.From, refRange.To
|
||||
args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
|
||||
@@ -72,13 +76,13 @@ 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, renderRaw)
|
||||
prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
|
||||
return types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
return types.NewMainViewDiffTaskWithPrefix(renderRaw, cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit))
|
||||
return types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), renderRaw)
|
||||
return types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
|
||||
@@ -100,7 +104,7 @@ func (self *DiffHelper) ExitDiffMode() error {
|
||||
|
||||
func (self *DiffHelper) RenderDiff() {
|
||||
args := self.DiffArgs()
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args)
|
||||
cmdObj := self.c.Git().Diff.DiffCmdObj(args, false)
|
||||
prefix := style.FgMagenta.Sprintf(
|
||||
"%s %s\n\n",
|
||||
self.c.Tr.ShowingGitDiff,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
)
|
||||
|
||||
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
|
||||
// navigation in view's displayed diff, starting from anchorViewLine. A "change block"
|
||||
// is lazygit's notion of a hunk — a run of consecutive added/deleted lines separated
|
||||
// by context, of which there may be several within one git @@ hunk — matching what
|
||||
// the staging view's hunk navigation jumps between. forward=true targets the start of
|
||||
// the next block; forward=false the start of the previous one (from mid-block this
|
||||
// skips to the previous block, mirroring State.SelectPreviousHunk). ok is false when
|
||||
// there's no further block, so the caller leaves the view where it is.
|
||||
func (self *StagingHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
contents := view.DiffLineContents()
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(contents)
|
||||
isChange := make([]bool, len(resolved))
|
||||
for i, r := range resolved {
|
||||
isChange[i] = r.ok && r.info.IsChange()
|
||||
}
|
||||
|
||||
target, ok := changeBlockStart(isChange, anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// AdjacentFile returns the view line to move to for next/previous file navigation in
|
||||
// view's (possibly multi-file) displayed diff, starting from anchorViewLine: the
|
||||
// first row belonging to the next/previous file, found where the per-row metadata's
|
||||
// file changes. ok is false at the first/last file.
|
||||
func (self *StagingHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
contents := view.DiffLineContents()
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(contents)
|
||||
paths := make([]string, len(resolved))
|
||||
for i, r := range resolved {
|
||||
if r.ok {
|
||||
paths[i] = r.info.Path
|
||||
}
|
||||
}
|
||||
|
||||
target, ok := fileStart(paths, anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// DiffFile is a file shown in a (possibly multi-file) diff: its absolute path and the
|
||||
// view line its section starts at — the row that next/previous-file navigation lands on.
|
||||
type DiffFile struct {
|
||||
Path string
|
||||
FirstViewLine int
|
||||
}
|
||||
|
||||
// FilesInDiff lists the files shown in view's diff, in display order, each paired with
|
||||
// the view line its section starts at. It is the jump-to-file menu's source: jumping to
|
||||
// a file goes to its FirstViewLine — the file's first located row, the same row that
|
||||
// AdjacentFile lands on, so the menu and n/N agree on where each file begins. A file
|
||||
// whose start row isn't currently mapped to a view line (not loaded yet) is skipped.
|
||||
func (self *StagingHelper) FilesInDiff(view *gocui.View) []DiffFile {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
paths := make([]string, len(resolved))
|
||||
for i, r := range resolved {
|
||||
if r.ok {
|
||||
paths[i] = r.info.Path
|
||||
}
|
||||
}
|
||||
|
||||
var files []DiffFile
|
||||
seen := map[string]bool{}
|
||||
for i, path := range paths {
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
if viewLine, ok := view.ViewLineForBufferLine(i); ok {
|
||||
files = append(files, DiffFile{Path: path, FirstViewLine: viewLine})
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// FirstChangeLineInView returns the view line of the first change line at or below
|
||||
// the top of the viewport, for placing the initial selection when focusing the main
|
||||
// view: we select the first change the user can already see rather than jumping to
|
||||
// the top of the diff (which would be jarring when the view is scrolled down). If
|
||||
// the top visible line is itself mid-change-block, that line is returned, so the
|
||||
// selection stays put. ok is false when no change line is loaded at or below the top
|
||||
// (e.g. scrolled into trailing context, or the diff isn't loaded that far yet),
|
||||
// leaving the caller to fall back.
|
||||
func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
|
||||
top, ok := view.BufferLineForViewLine(view.OriginY())
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
for i := top; i < len(resolved); i++ {
|
||||
if resolved[i].ok && resolved[i].info.IsChange() {
|
||||
return view.ViewLineForBufferLine(i)
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ViewHasChangeLines reports whether view's displayed diff contains any change line
|
||||
// (an addition or deletion), i.e. whether there's anything to select. It's false when
|
||||
// the main view shows a non-diff placeholder ("No changed files", a merge message) or a
|
||||
// diff that is somehow all context — the cases where the focused main view should show
|
||||
// no selection.
|
||||
func (self *StagingHelper) ViewHasChangeLines(view *gocui.View) bool {
|
||||
for _, resolved := range self.resolveDiffLines(view.DiffLineContents()) {
|
||||
if resolved.ok && resolved.info.IsChange() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsChangeLine reports whether the given view line of view's displayed diff is a
|
||||
// change line (an addition or deletion) rather than context, a header, or an
|
||||
// unparseable row — i.e. whether a click there points at something stageable.
|
||||
func (self *StagingHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
|
||||
info, ok := self.GetDiffLineInfoForView(view, viewLineIdx)
|
||||
return ok && info.IsChange()
|
||||
}
|
||||
|
||||
// ChangeBlockBounds returns the view-line range [start, end] of the change block
|
||||
// (lazygit's notion of a hunk; see AdjacentChangeBlock) to select when entering or
|
||||
// moving in hunk mode in view's displayed diff. The block is the one containing
|
||||
// anchorViewLine, or — when that line is context — the first block at or below it
|
||||
// (matching how toggling hunk mode in the staging view snaps to the next change).
|
||||
// ok is false when no change line lies at or below the anchor (e.g. scrolled into
|
||||
// trailing context, or the diff isn't loaded that far yet).
|
||||
func (self *StagingHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
isChange := make([]bool, len(resolved))
|
||||
for i, r := range resolved {
|
||||
isChange[i] = r.ok && r.info.IsChange()
|
||||
}
|
||||
|
||||
// Snap to the first change line at or after the anchor, then expand over the
|
||||
// whole contiguous run of change lines around it.
|
||||
start := anchor
|
||||
for start < len(isChange) && !isChange[start] {
|
||||
start++
|
||||
}
|
||||
if start >= len(isChange) {
|
||||
return 0, 0, false
|
||||
}
|
||||
end := start
|
||||
for start > 0 && isChange[start-1] {
|
||||
start--
|
||||
}
|
||||
for end < len(isChange)-1 && isChange[end+1] {
|
||||
end++
|
||||
}
|
||||
|
||||
startView, ok1 := view.ViewLineForBufferLine(start)
|
||||
endView, ok2 := view.ViewLineForBufferLine(end)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// ViewLineForBufferLine gives the first view line of the block's last buffer
|
||||
// line; extend over any further view lines it wrapped to, so the highlight
|
||||
// covers the whole block.
|
||||
for endView < view.ViewLinesHeight()-1 {
|
||||
next, ok := view.BufferLineForViewLine(endView + 1)
|
||||
if !ok || next != end {
|
||||
break
|
||||
}
|
||||
endView++
|
||||
}
|
||||
return startView, endView, true
|
||||
}
|
||||
|
||||
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
|
||||
// line of the change block adjacent to `from` in the given direction. It is the pure
|
||||
// index arithmetic behind AdjacentChangeBlock, mirroring the staging view's
|
||||
// State.SelectNextHunk / SelectPreviousHunk line by line.
|
||||
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
|
||||
if forward {
|
||||
i := from
|
||||
for i < len(isChange) && isChange[i] { // leave the current block
|
||||
i++
|
||||
}
|
||||
for i < len(isChange) && !isChange[i] { // skip the separating context
|
||||
i++
|
||||
}
|
||||
if i < len(isChange) {
|
||||
return i, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
i := from
|
||||
for i >= 0 && isChange[i] { // leave the current block
|
||||
i--
|
||||
}
|
||||
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
for i > 0 && isChange[i-1] { // walk back to that block's first line
|
||||
i--
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty
|
||||
// for a row no backend could place), the first located row of the file adjacent to
|
||||
// `from` in the given direction — the row file navigation lands on. It is the pure
|
||||
// index arithmetic behind AdjacentFile. A file is identified by its path, so we
|
||||
// find where the path changes, skipping unlocated rows: those are blank separator
|
||||
// rows between files, or the header rows of a pager that doesn't emit `f`/`h`
|
||||
// records. So the landing row is the file's header for any conforming source (a
|
||||
// parseable buffer, or a pager tagging its headers), and the first content line
|
||||
// under a pager that leaves its headers untagged — an accepted degradation. (An
|
||||
// earlier version instead backed up over the untagged rows above the first located
|
||||
// one, to reach the file's top under such pagers; but that overshoots onto the
|
||||
// blank line above the header whenever the headers themselves are tagged.)
|
||||
func fileStart(paths []string, from int, forward bool) (int, bool) {
|
||||
anchorPath, ok := anchorFilePath(paths, from)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" && paths[i] != anchorPath {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Walk back past the current file (its rows and any unlocated rows) to the
|
||||
// previous file's last located row, then back over that whole file, landing
|
||||
// on its first located row.
|
||||
i := from
|
||||
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
prevPath := paths[i]
|
||||
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
|
||||
i--
|
||||
}
|
||||
for paths[i] != prevPath {
|
||||
i++
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
// below it that carries a path — the file whose content is at or below the top of the
|
||||
// view — falling back to the nearest above when there's nothing below. Scanning down
|
||||
// first matters because the anchor is often an untagged file-header row whose nearest
|
||||
// tagged row is the *previous* file's content just above it; taking that would make
|
||||
// next-file navigation jump back into the file just left (so a second `n` wouldn't
|
||||
// advance). ok is false when no row carries a path.
|
||||
func anchorFilePath(paths []string, from int) (string, bool) {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
for i := from - 1; i >= 0; i-- {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChangeBlockStart(t *testing.T) {
|
||||
// A diff with three change blocks separated by context:
|
||||
// 0 file header 1 hunk header 2 context
|
||||
// 3 + 4 + (block A)
|
||||
// 5 context
|
||||
// 6 - (block B)
|
||||
// 7 context
|
||||
// 8 + (block C)
|
||||
isChange := []bool{false, false, false, true, true, false, true, false, true}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward from a header lands on the first block", 0, true, 3, true},
|
||||
{"forward from separating context lands on the next block", 5, true, 6, true},
|
||||
{"forward from the start of a block skips to the next", 3, true, 6, true},
|
||||
{"forward from inside a block skips the rest of it", 4, true, 6, true},
|
||||
{"forward from the last block finds nothing", 8, true, 0, false},
|
||||
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
|
||||
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
|
||||
{"backward from inside the first block finds nothing", 4, false, 0, false},
|
||||
{"backward from the first block's start finds nothing", 3, false, 0, false},
|
||||
{"backward from context lands on the preceding block's start", 7, false, 6, true},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := changeBlockStart(isChange, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStart(t *testing.T) {
|
||||
// A parseable two-file diff: every row carries its file's path (the headers
|
||||
// included), as the buffer parser reports.
|
||||
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
|
||||
|
||||
// The same diff as a pager without `f`/`h` header records emits it: only content
|
||||
// lines carry the path; the file/hunk header rows above each file are untagged
|
||||
// (empty), so navigation can only land on each file's first content line.
|
||||
tagged := []string{"", "", "a", "a", "", "", "b", "b"}
|
||||
|
||||
// Three such files, to exercise navigating from one file's untagged header to the
|
||||
// next: the row just above b's header is a's content, so the anchor file must be
|
||||
// found by scanning down (b), not up (a) — otherwise next-file would jump back
|
||||
// into b and a second `n` couldn't advance.
|
||||
taggedThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
|
||||
|
||||
// A pager that tags its header rows with `f`/`h` records (delta): the two-row
|
||||
// file header and the hunk-header box carry the file's path, but the blank
|
||||
// separator rows — above each file header, and between it and the first hunk
|
||||
// header — carry nothing. Navigation must land on the header's first row, not
|
||||
// the blank line above it.
|
||||
// 0 blank 1 file hdr 2 file hdr 3 blank 4-6 hunk hdr box 7 content
|
||||
// 8 blank 9 file hdr 10 file hdr 11 blank 12-13 hunk hdr box 14 content
|
||||
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
paths []string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"parseable: next file lands on its header", parseable, 1, true, 4, true},
|
||||
{"parseable: next from the last file finds nothing", parseable, 6, true, 0, false},
|
||||
{"parseable: previous file lands on its header", parseable, 5, false, 0, true},
|
||||
{"parseable: previous from the first file finds nothing", parseable, 1, false, 0, false},
|
||||
|
||||
// With only content tagged, both directions land on the file's first content
|
||||
// line — the untagged header rows above it can't be told apart from the
|
||||
// blank separator rows, so they are never a landing spot.
|
||||
{"tagged: next file lands on its first content line", tagged, 2, true, 6, true},
|
||||
{"tagged: next from an untagged header still advances", tagged, 0, true, 6, true},
|
||||
{"tagged: previous file lands on its first content line", tagged, 7, false, 2, true},
|
||||
{"tagged: previous from the first file finds nothing", tagged, 2, false, 0, false},
|
||||
|
||||
// From b's untagged header (row 4), the anchor file is b (below), so next goes
|
||||
// to c and previous goes to a — neither sticks on b.
|
||||
{"tagged: next from a middle file's header advances past it", taggedThree, 4, true, 10, true},
|
||||
{"tagged: previous from a middle file's header lands on the prior file", taggedThree, 4, false, 2, true},
|
||||
|
||||
// With headers tagged, both directions land on the header's first row —
|
||||
// crucially not on the blank separator row above it.
|
||||
{"header-tagged: next file lands on the header, not the blank above it", headerTagged, 7, true, 9, true},
|
||||
{"header-tagged: next from inside a file's header advances", headerTagged, 2, true, 9, true},
|
||||
{"header-tagged: previous file lands on the header, not the blank above it", headerTagged, 14, false, 1, true},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := fileStart(s.paths, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
|
||||
// unified diff.
|
||||
const diffFilePrefix = "diff --git "
|
||||
|
||||
// parsedDiffLine is what a backend recovers about a rendered diff row. RelPath
|
||||
// is the path as the source spells it — repo-relative from the diff header for
|
||||
// the buffer parser (#1), or whatever the pager emitted (possibly absolute) for
|
||||
// the OSC metadata (#2); the caller turns it into the absolute path of
|
||||
// types.DiffLineInfo.
|
||||
type parsedDiffLine struct {
|
||||
RelPath string
|
||||
Type types.DiffLineType
|
||||
NewLine int
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// bufferLineParse is the buffer-parse backend's result for one buffer line: the
|
||||
// recovered identity and whether the line could be resolved (false for a line in
|
||||
// an unparseable or restructured section, or outside any file section).
|
||||
type bufferLineParse struct {
|
||||
parsed parsedDiffLine
|
||||
ok bool
|
||||
}
|
||||
|
||||
// parseDiffLineFromBuffer recovers a rendered diff row's patch-space identity by
|
||||
// parsing the decolorized diff buffer (mechanism #1 in diff-line-metadata-notes.md).
|
||||
//
|
||||
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
|
||||
// resolve. A commit diff spans multiple files, so we isolate the file section
|
||||
// containing targetIdx and parse just that one (see parseFileSection). Use this for
|
||||
// a single line (e.g. a click); to resolve every line of a buffer, prefer
|
||||
// parseAllDiffLinesFromBuffer, which parses each section only once.
|
||||
//
|
||||
// ok is false when the buffer isn't a parseable unified diff at targetIdx (e.g.
|
||||
// a pager that restructures the diff, like delta's default mode), so the caller
|
||||
// can fall back to another backend.
|
||||
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
|
||||
if targetIdx < 0 || targetIdx >= len(bufferLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
start, end := fileSectionBounds(bufferLines, targetIdx)
|
||||
if start == -1 {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
r := parseFileSection(bufferLines[start:end])[targetIdx-start]
|
||||
return r.parsed, r.ok
|
||||
}
|
||||
|
||||
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file) diff
|
||||
// buffer in one pass, parsing each file section exactly once. It is the batch form
|
||||
// of parseDiffLineFromBuffer for callers that scan the whole buffer (the position-
|
||||
// restore and navigation scans): resolving line-by-line would re-parse each section
|
||||
// once per line — O(n²) on a large single-file diff — whereas this is O(n). The
|
||||
// returned slice is indexed 1:1 with bufferLines; a line in an unparseable section,
|
||||
// or before the first "diff --git", is left ok=false.
|
||||
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(bufferLines))
|
||||
for i := 0; i < len(bufferLines); {
|
||||
if !strings.HasPrefix(bufferLines[i], diffFilePrefix) {
|
||||
i++ // not in a file section yet; leave it unresolved
|
||||
continue
|
||||
}
|
||||
_, end := fileSectionBounds(bufferLines, i)
|
||||
copy(result[i:end], parseFileSection(bufferLines[i:end]))
|
||||
i = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fileSectionBounds returns the half-open range [start, end) of the file section
|
||||
// containing targetIdx: the nearest "diff --git" at or above it, up to the next one
|
||||
// (or the end of the buffer). start is -1 when targetIdx is before the first file
|
||||
// section.
|
||||
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
|
||||
start = -1
|
||||
for i := targetIdx; i >= 0; i-- {
|
||||
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start == -1 {
|
||||
return -1, -1
|
||||
}
|
||||
end = len(bufferLines)
|
||||
for i := start + 1; i < len(bufferLines); i++ {
|
||||
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
// parseFileSection parses one file's diff section (fileLines, starting at its
|
||||
// "diff --git" line) a single time and returns the resolved identity for each of
|
||||
// its lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with
|
||||
// the section's buffer lines, so the type and old/new line numbers fall straight
|
||||
// out of the patch arithmetic. Every line is left ok=false when the section has no
|
||||
// recoverable path or isn't a well-formed unified diff — the rendering restructured
|
||||
// it (e.g. delta's line-number gutters push the +/- marker off the start of the
|
||||
// line, so every body line reads as context), and trusting the mis-parse would land
|
||||
// us on the wrong line, so the caller should fall back to another backend.
|
||||
func parseFileSection(fileLines []string) []bufferLineParse {
|
||||
result := make([]bufferLineParse, len(fileLines))
|
||||
|
||||
relPath := pathFromDiffHeader(fileLines)
|
||||
if relPath == "" {
|
||||
return result
|
||||
}
|
||||
p := patch.Parse(strings.Join(fileLines, "\n"))
|
||||
if !p.IsWellFormed() {
|
||||
return result
|
||||
}
|
||||
patchLines := p.Lines()
|
||||
for i := range fileLines {
|
||||
if i >= len(patchLines) {
|
||||
break
|
||||
}
|
||||
parsed := parsedDiffLine{
|
||||
RelPath: relPath,
|
||||
Type: diffLineTypeForKind(patchLines[i].Kind),
|
||||
NewLine: p.LineNumberOfLine(i),
|
||||
}
|
||||
if parsed.Type == types.DiffLineDeleted {
|
||||
parsed.OldLine = p.OldLineNumberOfLine(i)
|
||||
}
|
||||
result[i] = bufferLineParse{parsed, true}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
|
||||
switch kind {
|
||||
case patch.PATCH_HEADER:
|
||||
return types.DiffLineFileHeader
|
||||
case patch.HUNK_HEADER:
|
||||
return types.DiffLineHunkHeader
|
||||
case patch.ADDITION:
|
||||
return types.DiffLineAdded
|
||||
case patch.DELETION:
|
||||
return types.DiffLineDeleted
|
||||
case patch.CONTEXT:
|
||||
return types.DiffLineContext
|
||||
default:
|
||||
return types.DiffLineOther
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffHeader extracts the new-file path of a single file's diff section.
|
||||
// It prefers the "+++ b/<path>" line (falling back to "--- a/<path>" when the
|
||||
// new path is /dev/null, i.e. a deleted file), and as a last resort the
|
||||
// "diff --git" line. Paths with characters git C-quotes are not handled (this is
|
||||
// a prototype); the common unquoted case is.
|
||||
func pathFromDiffHeader(fileLines []string) string {
|
||||
var oldPath, newPath string
|
||||
for _, line := range fileLines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break // past the header
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
newPath = stripDiffPathPrefix(strings.TrimPrefix(line, "+++ "))
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = stripDiffPathPrefix(strings.TrimPrefix(line, "--- "))
|
||||
}
|
||||
}
|
||||
|
||||
if newPath != "" && newPath != "/dev/null" {
|
||||
return newPath
|
||||
}
|
||||
if oldPath != "" && oldPath != "/dev/null" {
|
||||
return oldPath
|
||||
}
|
||||
return pathFromDiffGitLine(fileLines[0])
|
||||
}
|
||||
|
||||
// stripDiffPathPrefix removes git's default a/ or b/ diff path prefix if present.
|
||||
func stripDiffPathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
return path[2:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
|
||||
// line, used only when the +++/--- lines are absent.
|
||||
func pathFromDiffGitLine(line string) string {
|
||||
rest := strings.TrimPrefix(line, diffFilePrefix)
|
||||
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
|
||||
return rest[idx+len(" b/"):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseDiffLineMetadata parses mechanism #2's OSC 1717 payload (v1):
|
||||
// version;type;new-line;old-line;file — positional and ';'-delimited, with the
|
||||
// file last (so it may itself contain ';'), old-line empty unless the line is a
|
||||
// deletion, and new-line empty on a file header (the one type that carries no
|
||||
// line number). See diff-line-metadata-notes.md §9.2. ok is false for a payload
|
||||
// of an unknown version or shape, so the caller can fall back to another backend.
|
||||
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
|
||||
fields := strings.SplitN(payload, ";", 5)
|
||||
if len(fields) < 5 || fields[0] != "1" {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
lineType, ok := diffLineTypeFromMetadata(fields[1])
|
||||
if !ok {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
newLine := 0
|
||||
if fields[2] != "" {
|
||||
var err error
|
||||
if newLine, err = strconv.Atoi(fields[2]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
} else if lineType != types.DiffLineFileHeader {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
oldLine := 0
|
||||
if fields[3] != "" {
|
||||
var err error
|
||||
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
}
|
||||
|
||||
return parsedDiffLine{RelPath: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
|
||||
}
|
||||
|
||||
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
|
||||
switch typeField {
|
||||
case "c":
|
||||
return types.DiffLineContext, true
|
||||
case "a":
|
||||
return types.DiffLineAdded, true
|
||||
case "d":
|
||||
return types.DiffLineDeleted, true
|
||||
case "f":
|
||||
return types.DiffLineFileHeader, true
|
||||
case "h":
|
||||
return types.DiffLineHunkHeader, true
|
||||
default:
|
||||
return types.DiffLineOther, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
|
||||
// two consecutive deletions (grape, pear) that share a new-file line number;
|
||||
// file2 has two consecutive additions.
|
||||
const twoFileDiff = `diff --git a/file1.go b/file1.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.go
|
||||
+++ b/file1.go
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
diff --git a/dir/file2.go b/dir/file2.go
|
||||
index 3333333..4444444 100644
|
||||
--- a/dir/file2.go
|
||||
+++ b/dir/file2.go
|
||||
@@ -10,2 +9,4 @@ func foo() {
|
||||
ctx
|
||||
+added1
|
||||
+added2
|
||||
ctx2`
|
||||
|
||||
func TestParseDiffLineFromBuffer(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
targetIdx int
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"file header", 0, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
|
||||
{"hunk header", 4, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
|
||||
{"context line", 5, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
// The two deletions share new-file line 2 but have distinct old-file lines.
|
||||
{"first deletion", 6, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", 7, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// The second file: its path comes from the second "diff --git" section,
|
||||
// and its additions get distinct new-file line numbers.
|
||||
{"first addition", 15, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
|
||||
{"second addition", 16, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
|
||||
{"out of range", 999, parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
|
||||
// A rendering with no "diff --git" line (e.g. delta's default mode) can't be
|
||||
// parsed, so the caller falls back to another backend.
|
||||
bufferLines := []string{"some", "lines", "that", "are not a diff"}
|
||||
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
|
||||
// delta with line-number gutters keeps the diff/hunk headers but pushes the
|
||||
// +/- markers off the start of each body line, so every line reads as
|
||||
// context. The body no longer matches the hunk header, so we refuse to parse
|
||||
// (and the caller falls back) rather than return a confident mis-parse.
|
||||
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.txt
|
||||
+++ b/file1.txt
|
||||
@@ -1,5 +1,3 @@
|
||||
1 ⋮ 1 │ apple
|
||||
2 ⋮ │-grape
|
||||
3 ⋮ │-pear
|
||||
4 ⋮ 2 │ lemon
|
||||
5 ⋮ 3 │ mango`, "\n")
|
||||
|
||||
_, ok := parseDiffLineFromBuffer(mangled, 6)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineMetadata(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
payload string
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
// These payloads are exactly what the patched delta emits (verified
|
||||
// against the real binary; see diff-line-metadata-notes.md §9).
|
||||
{"context", "1;c;1;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
{"added", "1;a;3;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
|
||||
// A deletion carries both numbers; two consecutive deletions share the
|
||||
// new-file line and differ only in the old-file line.
|
||||
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// A whole-file deletion has new-file position 0 and the old path.
|
||||
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{RelPath: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
|
||||
// The path is the last field, so a ';' within it is preserved.
|
||||
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{RelPath: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
|
||||
// A pager may emit an absolute path; the parser keeps it verbatim (the
|
||||
// caller decides whether to join the worktree path).
|
||||
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{RelPath: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
|
||||
// A file header carries no line numbers; a hunk header carries the
|
||||
// new-file line of the hunk's first line (0 for a whole-file deletion,
|
||||
// mirroring `@@ -1,N +0,0 @@`).
|
||||
{"file header", "1;f;;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineFileHeader}, true},
|
||||
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
|
||||
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{RelPath: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
|
||||
// The spec leaves a file header's new-line always empty, but the parser
|
||||
// doesn't police a number being there — it just carries it along.
|
||||
{"file header with a line number (tolerated)", "1;f;10;;foo.txt", parsedDiffLine{RelPath: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
|
||||
|
||||
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
|
||||
{"too few fields", "1;c;1", parsedDiffLine{}, false},
|
||||
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
|
||||
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
|
||||
// Only a file header may omit the new-line; on any other type an empty
|
||||
// new-line is a malformed record, and rejecting it makes the row fall
|
||||
// back to the other backends rather than acting on wrong data.
|
||||
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
|
||||
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineMetadata(s.payload)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Helpers struct {
|
||||
FixupHelper *FixupHelper
|
||||
Commits *CommitsHelper
|
||||
SuspendResume *SuspendResumeHelper
|
||||
CommitFiles *CommitFilesHelper
|
||||
Snake *SnakeHelper
|
||||
// lives in context package because our contexts need it to render to main
|
||||
Diff *DiffHelper
|
||||
@@ -74,6 +75,7 @@ func NewStubHelpers() *Helpers {
|
||||
AmendHelper: &AmendHelper{},
|
||||
FixupHelper: &FixupHelper{},
|
||||
Commits: &CommitsHelper{},
|
||||
CommitFiles: &CommitFilesHelper{},
|
||||
Snake: &SnakeHelper{},
|
||||
Diff: &DiffHelper{},
|
||||
Repos: &ReposHelper{},
|
||||
|
||||
@@ -9,14 +9,17 @@ import (
|
||||
)
|
||||
|
||||
type PatchBuildingHelper struct {
|
||||
c *HelperCommon
|
||||
c *HelperCommon
|
||||
stagingHelper *StagingHelper
|
||||
}
|
||||
|
||||
func NewPatchBuildingHelper(
|
||||
c *HelperCommon,
|
||||
stagingHelper *StagingHelper,
|
||||
) *PatchBuildingHelper {
|
||||
return &PatchBuildingHelper{
|
||||
c: c,
|
||||
c: c,
|
||||
stagingHelper: stagingHelper,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +35,59 @@ func (self *PatchBuildingHelper) ShowHunkStagingHint() {
|
||||
}
|
||||
}
|
||||
|
||||
// takes us from the patch building panel back to the commit files panel
|
||||
// takes us from the patch building panel back to the commit files panel, or to
|
||||
// the focused main view if that's where we entered it from
|
||||
func (self *PatchBuildingHelper) Escape() {
|
||||
self.c.Context().Pop()
|
||||
EscapeFromPatchExplorer(self.c, self.stagingHelper, self.c.Contexts().CustomPatchBuilder)
|
||||
}
|
||||
|
||||
// EscapeFromPatchExplorer returns from a patch explorer context (staging or
|
||||
// patch building). If we entered it from a focused main view, we go back to
|
||||
// where we came from (re-rendering the side panel's content into the main view,
|
||||
// like the plain escape does), then focus the main view and land on the line the
|
||||
// explorer currently has selected. Otherwise we just pop to the side panel.
|
||||
func EscapeFromPatchExplorer(c *HelperCommon, stagingHelper *StagingHelper, context types.IPatchExplorerContext) {
|
||||
snapshot := context.GetFocusedMainViewSnapshot()
|
||||
if snapshot == nil {
|
||||
c.Context().Pop()
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the snapshot wherever it was set. The staging view records it on both
|
||||
// its halves (see FilesController.EnterFile) so escape works after the selection
|
||||
// crosses between them; clear both so a stale one can't linger. For patch
|
||||
// building it's only on the context we're escaping from.
|
||||
context.SetFocusedMainViewSnapshot(nil)
|
||||
c.Contexts().Staging.SetFocusedMainViewSnapshot(nil)
|
||||
c.Contexts().StagingSecondary.SetFocusedMainViewSnapshot(nil)
|
||||
|
||||
// Restore the side panel's selection before we render it, so it shows the
|
||||
// same content the main view had (diving into staging can change it, e.g.
|
||||
// from a directory to a file in the files panel).
|
||||
if listContext, ok := snapshot.SidePanel.(types.IListContext); ok && snapshot.SidePanelSelectedLineIdx >= 0 {
|
||||
listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx)
|
||||
}
|
||||
|
||||
// Ask the upcoming re-render of the main view to land on the line the explorer
|
||||
// currently has selected. Read that identity now, before the pushes: pushing
|
||||
// the side panel re-renders its content into the main view, and the restore
|
||||
// rides that re-render — finding the matching row as it loads and scrolling to
|
||||
// and selecting it as the content first appears. See RestoreFocusedMainViewOnEscape.
|
||||
//
|
||||
// Anchor on the *first* line of the explorer's selection: in hunk or range mode
|
||||
// the selection spans several lines and its cursor sits at the last one, but
|
||||
// returning to the start of the hunk is what reads as "the same place".
|
||||
selectedViewLine := context.GetView().SelectedLineIdx()
|
||||
if state := context.GetState(); state != nil {
|
||||
selectedViewLine, _ = state.SelectedViewRange()
|
||||
}
|
||||
stagingHelper.RestoreFocusedMainViewOnEscape(
|
||||
context.GetView(), snapshot.MainView.GetView(), selectedViewLine)
|
||||
|
||||
// Land on the side panel first (this re-renders the original content into the
|
||||
// main view), then focus the main view on top of it.
|
||||
c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{})
|
||||
c.Context().Push(snapshot.MainView, types.OnFocusOpts{})
|
||||
}
|
||||
|
||||
// kills the custom patch and returns us back to the commit files panel if needed
|
||||
@@ -56,8 +109,10 @@ func (self *PatchBuildingHelper) Reset() error {
|
||||
|
||||
func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) {
|
||||
selectedLineIdx := -1
|
||||
selectedRealLineIdx := -1
|
||||
if opts.ClickedWindowName == "main" {
|
||||
selectedLineIdx = opts.ClickedViewLineIdx
|
||||
selectedRealLineIdx = opts.ClickedViewRealLineIdx
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
@@ -90,7 +145,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
|
||||
|
||||
oldState := context.GetState()
|
||||
|
||||
state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView)
|
||||
state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, opts.ClickedViewRealLineIsDeletion, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode)
|
||||
context.SetState(state)
|
||||
if state == nil {
|
||||
self.Escape()
|
||||
|
||||
@@ -371,11 +371,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
|
||||
|
||||
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
|
||||
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
|
||||
var capturedCommitFiles capturedCommitFilesState
|
||||
var capturedCommitFiles *capturedCommitFilesState
|
||||
self.captureOnUIThread(calledFromWorker, env.background, func() {
|
||||
capturedCommitFiles = self.captureCommitFilesState()
|
||||
})
|
||||
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
|
||||
if capturedCommitFiles != nil {
|
||||
refresh("commit files", func() { _ = self.refreshCommitFilesContext(*capturedCommitFiles, env) })
|
||||
}
|
||||
}
|
||||
|
||||
fileWg := sync.WaitGroup{}
|
||||
@@ -752,10 +754,12 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS
|
||||
// Capture the diff endpoints here, on the UI thread and after
|
||||
// ReInit has set them, before dispatching the git work.
|
||||
capturedCommitFiles := self.captureCommitFilesState()
|
||||
self.onWorker(env.background, func(gocui.Task) error {
|
||||
_ = self.refreshCommitFilesContext(capturedCommitFiles, env)
|
||||
return nil
|
||||
})
|
||||
if capturedCommitFiles != nil {
|
||||
self.onWorker(env.background, func(gocui.Task) error {
|
||||
_ = self.refreshCommitFilesContext(*capturedCommitFiles, env)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1013,10 +1017,18 @@ type capturedCommitFilesState struct {
|
||||
|
||||
// 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 {
|
||||
func (self *RefreshHelper) captureCommitFilesState() *capturedCommitFilesState {
|
||||
// The commit files context may have no ref associated with it yet — e.g. a custom
|
||||
// patch was started straight from the commits panel's main view without ever entering
|
||||
// the commit files panel. There are then no commit files to load (and deriving a diff
|
||||
// range from a nil ref would panic), so there's nothing to refresh.
|
||||
if self.c.Contexts().CommitFiles.GetRef() == nil && self.c.Contexts().CommitFiles.GetRefRange() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
from, to := self.c.Contexts().CommitFiles.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}
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var lazygitEditURLRegexp = regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`)
|
||||
|
||||
type StagingHelper struct {
|
||||
c *HelperCommon
|
||||
c *HelperCommon
|
||||
windowHelper *WindowHelper
|
||||
|
||||
// pagerMetadataSupport caches whether the configured pager speaks the
|
||||
// diff-line-metadata protocol (nil until first probed), so the focused main view
|
||||
// knows whether it can act on the pager's diff or must render it raw (see
|
||||
// DiffMainViewShouldRenderRaw). Keyed on pagerSupportSig; re-probed when the pager
|
||||
// changes (a cycle or a config reload).
|
||||
pagerMetadataSupport *bool
|
||||
pagerSupportSig string
|
||||
}
|
||||
|
||||
func NewStagingHelper(
|
||||
c *HelperCommon,
|
||||
windowHelper *WindowHelper,
|
||||
) *StagingHelper {
|
||||
return &StagingHelper{
|
||||
c: c,
|
||||
c: c,
|
||||
windowHelper: windowHelper,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +53,16 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
|
||||
}
|
||||
|
||||
mainSelectedLineIdx := -1
|
||||
mainSelectedRealLineIdx := -1
|
||||
secondarySelectedLineIdx := -1
|
||||
secondarySelectedRealLineIdx := -1
|
||||
if focusOpts.ClickedViewLineIdx > 0 {
|
||||
if secondaryFocused {
|
||||
secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx
|
||||
secondarySelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx
|
||||
} else {
|
||||
mainSelectedLineIdx = focusOpts.ClickedViewLineIdx
|
||||
mainSelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +91,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
|
||||
|
||||
hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView
|
||||
mainContext.SetState(
|
||||
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode),
|
||||
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
)
|
||||
|
||||
secondaryContext.SetState(
|
||||
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode),
|
||||
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
)
|
||||
|
||||
mainState := mainContext.GetState()
|
||||
@@ -125,3 +152,868 @@ func (self *StagingHelper) secondaryStagingFocused() bool {
|
||||
func (self *StagingHelper) mainStagingFocused() bool {
|
||||
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey()
|
||||
}
|
||||
|
||||
// GetDiffLineInfo recovers the patch-space identity — (file, type, new-line,
|
||||
// old-line) — of a rendered diff row, given the window showing the diff and the
|
||||
// (wrapped) view line index. It is the single seam the focused main view and
|
||||
// patch explorer consumers go through to act on the line the user is pointing
|
||||
// at, and the strategy behind it is swappable (see diff-line-metadata-notes.md).
|
||||
//
|
||||
// It tries three backends in order of fidelity. First, mechanism #2: per-line
|
||||
// OSC metadata emitted by a patched pager (delta), which carries the side
|
||||
// directly and so serves the renderings #1 can't parse — delta's default mode,
|
||||
// --line-numbers, diff-so-fancy. Failing that, mechanism #1: parsing the
|
||||
// decolorized view buffer, which serves the structure-preserving renderings (no
|
||||
// pager, git diff --color, delta --color-only, diff-so-fancy --patch). Failing
|
||||
// that, delta's lazygit-edit:// hyperlinks; the hyperlink can't convey the side,
|
||||
// so its result is reported as a non-deletion content line.
|
||||
func (self *StagingHelper) GetDiffLineInfo(windowName string, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
|
||||
if v == nil {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
return self.GetDiffLineInfoForView(v, viewLineIdx)
|
||||
}
|
||||
|
||||
// ChangeLinesInViewRange resolves every change line (addition/deletion) in the
|
||||
// inclusive view-line range [first, last] of the diff shown in the given window, as
|
||||
// the patch-space identities to stage. It is the range form of GetDiffLineInfo,
|
||||
// behind staging a selection from the focused main view. Context and header lines
|
||||
// are skipped (Transform emits context regardless of the included set, so only
|
||||
// change lines need collecting — see §21.3), and view lines that wrap to the same
|
||||
// buffer line are de-duplicated.
|
||||
//
|
||||
// A side-by-side rendering carries more than one record per row — a deletion on the
|
||||
// left, the addition replacing it on the right — and staging includes both (you
|
||||
// can't stage one side of a side-by-side row; accepted restriction). So each row's
|
||||
// metadata payloads are all resolved; rows without metadata (no pager, or the
|
||||
// buffer-parse / hyperlink backends) fall back to their single resolved record.
|
||||
func (self *StagingHelper) ChangeLinesInViewRange(windowName string, first int, last int) []types.DiffLineInfo {
|
||||
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(v.DiffLineContents())
|
||||
payloadsByLine := v.DiffLineMetadataPayloads()
|
||||
var infos []types.DiffLineInfo
|
||||
lastBufferLine := -1
|
||||
for viewLine := first; viewLine <= last; viewLine++ {
|
||||
bufferLine, ok := v.BufferLineForViewLine(viewLine)
|
||||
if !ok || bufferLine == lastBufferLine {
|
||||
continue
|
||||
}
|
||||
lastBufferLine = bufferLine
|
||||
|
||||
if bufferLine < len(payloadsByLine) && len(payloadsByLine[bufferLine]) > 0 {
|
||||
for _, payload := range payloadsByLine[bufferLine] {
|
||||
if info, ok := self.diffLineInfoFromMetadata(payload); ok && info.IsChange() {
|
||||
infos = append(infos, info)
|
||||
}
|
||||
}
|
||||
} else if bufferLine < len(resolved) && resolved[bufferLine].ok && resolved[bufferLine].info.IsChange() {
|
||||
infos = append(infos, resolved[bufferLine].info)
|
||||
}
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// ChangeLineOrdinalsInViewRange resolves the selected view-line range [first, last] of a
|
||||
// custom-patch (secondary) diff to, per file, the ordinals of the selected change lines
|
||||
// among that file's change lines (0-based, in displayed order), keyed by the file's
|
||||
// absolute path. The custom-patch view shows exactly the change lines included in the
|
||||
// patch, in order, so the k-th change line of a file maps to that file's k-th included
|
||||
// change line — which PatchBuilder.IncludedChangeLineIndices turns into a patch-line index
|
||||
// to remove. This is how the focused main view removes lines from the custom patch without
|
||||
// matching by line number, which the aggregated patch renumbers (unreliable for additions).
|
||||
//
|
||||
// The whole buffer is scanned to count each file's change lines from its start; only the
|
||||
// lines inside the selected range are reported. View lines that wrap to the same buffer
|
||||
// line are de-duplicated by the buffer-line walk.
|
||||
func (self *StagingHelper) ChangeLineOrdinalsInViewRange(windowName string, first int, last int) map[string][]int {
|
||||
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
firstBuffer, ok := v.BufferLineForViewLine(first)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
lastBuffer, ok := v.BufferLineForViewLine(last)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(v.DiffLineContents())
|
||||
countByFile := map[string]int{}
|
||||
result := map[string][]int{}
|
||||
for i, r := range resolved {
|
||||
if !r.ok || !r.info.IsChange() {
|
||||
continue
|
||||
}
|
||||
ordinal := countByFile[r.info.Path]
|
||||
countByFile[r.info.Path]++
|
||||
if i >= firstBuffer && i <= lastBuffer {
|
||||
result[r.info.Path] = append(result[r.info.Path], ordinal)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetDiffLineInfoForView is GetDiffLineInfo against a specific view rather than
|
||||
// one looked up by window. It is used to read the identity of the line the patch
|
||||
// explorer currently has selected when escaping back to the focused main view,
|
||||
// where we hold the explorer's view directly.
|
||||
func (self *StagingHelper) GetDiffLineInfoForView(v *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
// A click/cursor lands on a (wrapped) view line; resolve it to the unwrapped
|
||||
// buffer line all three backends key off, then read that buffer line's content.
|
||||
bufferLineIdx, ok := v.BufferLineForViewLine(viewLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
return self.diffLineInfoFromContents(v.DiffLineContents(), bufferLineIdx)
|
||||
}
|
||||
|
||||
// findResolvedDiffLine returns the index of the first line, at or after from, that
|
||||
// match accepts as the target, or -1 if none does. It is the inverse direction of the
|
||||
// diff-line primitive: instead of resolving the line under a cursor, it scans a
|
||||
// rendered diff (resolved once via resolveDiffLines) for a known identity. The
|
||||
// position restore uses it to locate, in the re-rendered view, the line it wants to
|
||||
// land on; match is how that restore decides a row is the target (see diffLineMatch).
|
||||
func findResolvedDiffLine(resolved []resolvedDiffLine, target types.DiffLineInfo, match diffLineMatch, from int) int {
|
||||
for i := from; i < len(resolved); i++ {
|
||||
if resolved[i].ok && match(target, resolved[i].info) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// diffLineMatch decides whether a re-rendered row is the target a restore is looking
|
||||
// for. matchByPatchLine is currently the only one — the post-stage reveal, the other
|
||||
// case where a line's number can change across the re-render, matches by ordinal
|
||||
// instead (see RevealSelectionAfterStaging).
|
||||
type diffLineMatch func(target, row types.DiffLineInfo) bool
|
||||
|
||||
// matchByPatchLine matches by source-line number (DiffLineInfo.SamePatchLine). It is
|
||||
// for restores whose target keeps its source-line number across the re-render — the
|
||||
// escape restore and the context-size preserve, which re-render the same
|
||||
// staged/unstaged state, so only context lines (not change lines) can come or go.
|
||||
func matchByPatchLine(target, row types.DiffLineInfo) bool {
|
||||
return row.SamePatchLine(target)
|
||||
}
|
||||
|
||||
// RestoreFocusedMainViewOnEscape arranges, when escaping a patch explorer back to
|
||||
// the focused main view it was entered from, for that view to re-render and then
|
||||
// land on the line the explorer currently has selected. After staging or dropping
|
||||
// hunks the explorer's selection auto-advances, so the line the user ended up on
|
||||
// is more useful to return to than the one they entered on — and, since the diff
|
||||
// has changed, more reliable than replaying a numeric scroll/index.
|
||||
//
|
||||
// It reads that line's patch identity from the explorer now (explorerView,
|
||||
// explorerSelectedLineIdx), then installs a restore that, as the main view
|
||||
// re-renders, scans the incoming content for the row matching that identity and —
|
||||
// once it and a screenful below have loaded — swaps in, scrolls there and selects
|
||||
// it. If the identity can't be read, or the line never turns up in the re-render
|
||||
// (the content changed out from under it), no restore is applied and the view
|
||||
// just re-renders normally.
|
||||
func (self *StagingHelper) RestoreFocusedMainViewOnEscape(explorerView, mainView *gocui.View, explorerSelectedLineIdx int) {
|
||||
target, ok := self.GetDiffLineInfoForView(explorerView, explorerSelectedLineIdx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
self.restoreDiffLinePositionOnRerender(mainView, []diffLineAnchor{{identity: target}}, matchByPatchLine, func(_ diffLineAnchor, viewLine int) {
|
||||
// scrollIntoView centres the line if it's off-screen, and leaves the scroll
|
||||
// untouched if it's already visible — so for the common unchanged-content
|
||||
// escape (the placeholder is the same content at the same scroll) nothing
|
||||
// moves.
|
||||
mainView.FocusPoint(0, viewLine, true)
|
||||
mainView.Highlight = true
|
||||
mainView.HighlightInactive = false
|
||||
})
|
||||
}
|
||||
|
||||
// RevealSelectionAfterStaging arranges, after staging or unstaging re-renders the
|
||||
// focused main view's diff, for the selection to land on the change nearest the one
|
||||
// just acted on rather than at a stale (and possibly off-content) position — the same
|
||||
// "advance to the next change" the staging view does. Install it before triggering the
|
||||
// re-render: it rides the next render of targetView, like RestoreFocusedMainViewOnEscape.
|
||||
// place re-selects the landed line in the current select mode.
|
||||
//
|
||||
// It works by preserving the selection's ordinal among change lines, exactly as the
|
||||
// staging view preserves its patch-line index: the acted-on line's ordinal is read
|
||||
// from sourceView (the pane the user acted in) before the op, and after the re-render
|
||||
// the change line at that ordinal in targetView is selected. Because the op removes the
|
||||
// acted-on change line(s), that ordinal then holds the next surviving change — the next
|
||||
// line of the same block when one line was staged, the next block when a whole block
|
||||
// was. This sidesteps matching by line number, which can't identify the target across a
|
||||
// stage: a deletion shares its new-file number with the rest of its block, and in the
|
||||
// staged pane the new-file number is the index, which the op shifts.
|
||||
//
|
||||
// sourceView and targetView are usually the same pane, but staging or unstaging can move
|
||||
// the acted-on side to the other pane (e.g. unstaging the first hunk of an only-staged
|
||||
// file splits it, pushing the staged remainder to the secondary half); the ordinal is
|
||||
// preserved across that move since both panes show the same side.
|
||||
//
|
||||
// advanceBy shifts the landed ordinal forward by that many change lines. Staging and
|
||||
// unstaging consume the acted-on lines, so the preserved ordinal (advanceBy 0) already
|
||||
// lands on the next change. A custom-patch toggle leaves the diff unchanged, so preserving
|
||||
// the ordinal would land back on the just-toggled lines; passing the toggled change-line
|
||||
// count advances past them to the next stageable hunk/line, matching the staging feel.
|
||||
func (self *StagingHelper) RevealSelectionAfterStaging(sourceView *gocui.View, targetView *gocui.View, firstLine int, advanceBy int, place func(viewLine int)) {
|
||||
ordinal, ok := self.changeLineOrdinal(sourceView, firstLine)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
self.revealChangeLineAtOrdinal(targetView, ordinal+advanceBy, place)
|
||||
}
|
||||
|
||||
// changeLineOrdinal returns how many change lines precede the (change) line at
|
||||
// viewLine in view's displayed diff — i.e. that line's index among the diff's change
|
||||
// lines. ok is false when viewLine maps to no buffer line.
|
||||
func (self *StagingHelper) changeLineOrdinal(view *gocui.View, viewLine int) (int, bool) {
|
||||
bufferLine, ok := view.BufferLineForViewLine(viewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
ordinal := 0
|
||||
for i := 0; i < bufferLine && i < len(resolved); i++ {
|
||||
if resolved[i].ok && resolved[i].info.IsChange() {
|
||||
ordinal++
|
||||
}
|
||||
}
|
||||
return ordinal, true
|
||||
}
|
||||
|
||||
// diffLineAnchor is a candidate line for a restore to land on after a re-render: a
|
||||
// patch identity to find the line by, plus the screen row (offset from the view's
|
||||
// top) it was on when captured, for a restore that wants to put it back there.
|
||||
type diffLineAnchor struct {
|
||||
identity types.DiffLineInfo
|
||||
row int
|
||||
}
|
||||
|
||||
// installDiffLineRestore is the shared core of the position restores. It sets a
|
||||
// restore on view's render manager that, as view next re-renders, locates a target
|
||||
// buffer line and places it: findEarly is given the rows that have loaded since the
|
||||
// previous call (resolvable on their own via OSC metadata or a lazygit-edit hyperlink)
|
||||
// so the target can be found and painted early when possible; findComplete is given
|
||||
// the whole resolved diff at the EOF swap for the definitive target (and the
|
||||
// buffer-parse fallback, which only resolves once whole hunks have loaded). Each
|
||||
// returns the anchor to hand back to place — the matched candidate for an identity
|
||||
// restore, a zero anchor for a positional one — and the target buffer line. place
|
||||
// receives that anchor and the target's view line; if neither finder produces a
|
||||
// target, place isn't called and the view re-renders normally.
|
||||
//
|
||||
// The find runs against the off-screen buffer before the swap, so the (possibly
|
||||
// whole-diff) scan happens while the previous content is still displayed; otherwise
|
||||
// the new content would be drawn at the old scroll for the scan's duration.
|
||||
func (self *StagingHelper) installDiffLineRestore(
|
||||
view *gocui.View,
|
||||
findEarly func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool),
|
||||
findComplete func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool),
|
||||
place func(anchor diffLineAnchor, viewLine int),
|
||||
) {
|
||||
// Get-or-create: the target pane may not have rendered yet (the secondary half
|
||||
// when a stage/unstage first splits the diff), so the restore has to be set on a
|
||||
// manager that the upcoming render will then reuse.
|
||||
manager := self.c.GetOrCreateViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// scanned tracks how far findEarly has looked, so each line is checked once
|
||||
// (keeping the incremental scan O(n) over the whole load).
|
||||
primaryAnchor := diffLineAnchor{}
|
||||
primaryBufferLine := -1
|
||||
scanned := 0
|
||||
manager.SetRestoreForNextTask(&tasks.RenderRestore{
|
||||
FirstPaintReady: func() bool {
|
||||
if primaryBufferLine == -1 {
|
||||
newRows := view.OffscreenDiffLineContentsFrom(scanned)
|
||||
if anchor, line, ok := findEarly(newRows, scanned); ok {
|
||||
primaryAnchor, primaryBufferLine = anchor, line
|
||||
}
|
||||
scanned += len(newRows)
|
||||
if primaryBufferLine == -1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return view.OffscreenLineCount() >= primaryBufferLine+view.InnerHeight()
|
||||
},
|
||||
Apply: func(swapIn func()) {
|
||||
anchor, bufferLine := primaryAnchor, primaryBufferLine
|
||||
if bufferLine == -1 {
|
||||
if a, line, ok := findComplete(self.resolveDiffLines(view.OffscreenDiffLineContents())); ok {
|
||||
anchor, bufferLine = a, line
|
||||
}
|
||||
}
|
||||
|
||||
swapIn()
|
||||
|
||||
if bufferLine != -1 {
|
||||
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
|
||||
place(anchor, viewLine)
|
||||
}
|
||||
}
|
||||
manager.ClearRestoreForNextTask()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// restoreDiffLinePositionOnRerender installs a restore that, as view next re-renders,
|
||||
// lands on a row that match accepts as one of the given candidate identities, and calls
|
||||
// place with that candidate and its view line. candidates are in priority order
|
||||
// (nearest first); the restore lands on the first the re-render still contains, found
|
||||
// incrementally for the nearest and at the EOF swap for any farther fallback (candidates
|
||||
// aren't in load order, so a nearer one can load after a farther one and we mustn't
|
||||
// commit prematurely). If none turns up, place isn't called. match decides what "still
|
||||
// contains" means, since the stable notion of identity differs by what changed in the
|
||||
// re-render (see diffLineMatch).
|
||||
//
|
||||
// It is the identity-matched restore behind escaping back to the focused main view (one
|
||||
// candidate — the line the patch explorer had selected) and preserving a diff view's
|
||||
// position across a -U context-size change (several candidates around the anchor, placed
|
||||
// back where they were; see PreserveDiffPositionOnRerender). The post-stage reveal uses
|
||||
// the positional restore instead (see revealChangeLineAtOrdinal).
|
||||
func (self *StagingHelper) restoreDiffLinePositionOnRerender(view *gocui.View, candidates []diffLineAnchor, match diffLineMatch, place func(anchor diffLineAnchor, viewLine int)) {
|
||||
if len(candidates) == 0 {
|
||||
return
|
||||
}
|
||||
self.installDiffLineRestore(view,
|
||||
func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool) {
|
||||
for j, content := range rows {
|
||||
if info, ok := self.diffLineInfoPerRow(content); ok && match(candidates[0].identity, info) {
|
||||
return candidates[0], offset + j, true
|
||||
}
|
||||
}
|
||||
return diffLineAnchor{}, 0, false
|
||||
},
|
||||
func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool) {
|
||||
for _, candidate := range candidates {
|
||||
if line := findResolvedDiffLine(resolved, candidate.identity, match, 0); line != -1 {
|
||||
return candidate, line, true
|
||||
}
|
||||
}
|
||||
return diffLineAnchor{}, 0, false
|
||||
},
|
||||
place,
|
||||
)
|
||||
}
|
||||
|
||||
// revealChangeLineAtOrdinal installs a restore that, as view next re-renders, selects
|
||||
// the change line at the given ordinal among the diff's change lines (clamped to the
|
||||
// last when the re-render has fewer). It is the positional restore behind the post-stage
|
||||
// reveal: see RevealSelectionAfterStaging for why an ordinal, not an identity.
|
||||
func (self *StagingHelper) revealChangeLineAtOrdinal(view *gocui.View, ordinal int, place func(viewLine int)) {
|
||||
seen := 0 // change lines counted so far by the incremental scan
|
||||
self.installDiffLineRestore(view,
|
||||
func(rows []gocui.DiffLineContent, offset int) (diffLineAnchor, int, bool) {
|
||||
for j, content := range rows {
|
||||
if info, ok := self.diffLineInfoPerRow(content); ok && info.IsChange() {
|
||||
if seen == ordinal {
|
||||
return diffLineAnchor{}, offset + j, true
|
||||
}
|
||||
seen++
|
||||
}
|
||||
}
|
||||
return diffLineAnchor{}, 0, false
|
||||
},
|
||||
func(resolved []resolvedDiffLine) (diffLineAnchor, int, bool) {
|
||||
last, count := -1, 0
|
||||
for i, r := range resolved {
|
||||
if r.ok && r.info.IsChange() {
|
||||
last = i
|
||||
if count == ordinal {
|
||||
return diffLineAnchor{}, i, true
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Fewer change lines than the ordinal (the acted-on line was at or near the
|
||||
// end): clamp to the last surviving change.
|
||||
return diffLineAnchor{}, last, last != -1
|
||||
},
|
||||
func(_ diffLineAnchor, viewLine int) { place(viewLine) },
|
||||
)
|
||||
}
|
||||
|
||||
// PreserveDiffPositionOnRerender remembers where a diff view is anchored and, when
|
||||
// it next re-renders, restores that position instead of letting it jump to the top.
|
||||
// It is the diff-line primitive's context-change consumer: the increase/decrease
|
||||
// context-size keybindings re-render the diff with a different git command, which
|
||||
// otherwise resets the scroll. Call it on the view about to be re-rendered, right
|
||||
// before triggering the re-render.
|
||||
//
|
||||
// The anchor is the selected line if a selection is showing, otherwise the line in
|
||||
// the middle of the visible content (so what stays put is the line you're most likely
|
||||
// looking at, not the top edge). We'd like to keep the anchor line itself, but it may
|
||||
// not survive the re-render: a context line vanishes when the context size shrinks. So
|
||||
// we collect the
|
||||
// lines around the anchor as fallbacks (nearest first; see nearbyDiffLines) and land
|
||||
// on the nearest one that survives — the anchor itself when it does, otherwise the
|
||||
// closest surviving line, which minimises scrolling. Each is put back at the same
|
||||
// screen row it was on. A showing selection is re-established on the landed line;
|
||||
// otherwise the view stays in scroll mode.
|
||||
//
|
||||
// A range or hunk selection has two ends, so its far end (the range anchor opposite the
|
||||
// cursor) is remembered by patch identity too and put back by that identity in the
|
||||
// re-render — not by screen line, which a restructuring pager (e.g. delta side-by-side)
|
||||
// makes mean something different. So the selection still spans the same lines even when
|
||||
// the diff is laid out differently. Restoring the cursor is enough for a single-line
|
||||
// selection; this just extends the same restore to the other end.
|
||||
func (self *StagingHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
|
||||
// If the view isn't the one currently shown in its window it won't be the one
|
||||
// re-rendered (e.g. the merge-conflicts view occupies the main window), so a
|
||||
// restore set on it would linger and wrongly suppress a later render's scroll
|
||||
// reset. There's nothing to preserve then.
|
||||
if !view.Visible {
|
||||
return
|
||||
}
|
||||
|
||||
showSelection := view.Highlight
|
||||
anchorViewLine := view.MiddleVisibleLineIdx()
|
||||
if showSelection {
|
||||
anchorViewLine = view.SelectedLineIdx()
|
||||
}
|
||||
|
||||
// The far end of a range/hunk selection, by patch identity, to re-establish below;
|
||||
// not set (false) for a single-line selection.
|
||||
farEnd, hasFarEnd := types.DiffLineInfo{}, false
|
||||
if showSelection {
|
||||
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
|
||||
}
|
||||
|
||||
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine), matchByPatchLine, func(anchor diffLineAnchor, viewLine int) {
|
||||
// Put the landed line back on the screen row it was captured on, clamped into
|
||||
// the view in case it was off-screen (a fallback line can be), so the restore
|
||||
// always lands somewhere visible.
|
||||
row := max(0, min(anchor.row, view.InnerHeight()-1))
|
||||
view.SetOrigin(0, viewLine-row)
|
||||
if showSelection {
|
||||
// Re-establish the range's far end (by the identity captured above) before
|
||||
// placing the cursor, so the selection spans the same lines again. Start from
|
||||
// a clean slate; if the far end didn't survive the re-render, leave a single
|
||||
// line. SetOrigin already placed the row, so the cursor move mustn't scroll.
|
||||
view.CancelRangeSelect()
|
||||
if hasFarEnd {
|
||||
if farViewLine, ok := self.findDiffLine(view, farEnd); ok {
|
||||
view.SetRangeSelectStart(farViewLine)
|
||||
}
|
||||
}
|
||||
view.FocusPoint(0, viewLine, false)
|
||||
view.Highlight = true
|
||||
view.HighlightInactive = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// selectionFarEndIdentity returns the patch identity of a range or hunk selection's far
|
||||
// end — the range anchor opposite the cursor — so a re-render can put the range back over
|
||||
// the same lines (see PreserveDiffPositionOnRerender). ok is false for a single-line
|
||||
// selection, or when that end doesn't resolve to a diff line.
|
||||
func (self *StagingHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
|
||||
first, last := view.SelectedLineRange()
|
||||
if first == last {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
farEnd := last
|
||||
if view.SelectedLineIdx() == last {
|
||||
farEnd = first
|
||||
}
|
||||
return self.GetDiffLineInfoForView(view, farEnd)
|
||||
}
|
||||
|
||||
// findDiffLine returns the view line of the first row in view's currently displayed diff
|
||||
// whose patch identity matches the given one, or false if none does — the lookup behind
|
||||
// restoring a remembered position (a range selection's far end) once the diff has
|
||||
// re-rendered.
|
||||
func (self *StagingHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
line := findResolvedDiffLine(resolved, identity, matchByPatchLine, 0)
|
||||
if line == -1 {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(line)
|
||||
}
|
||||
|
||||
// nearbyDiffLines collects the resolvable diff lines around the given view line in
|
||||
// view's displayed diff, as restore candidates ordered by proximity (the anchor line
|
||||
// itself first, then outward, preferring at-or-below on ties), each tagged with the
|
||||
// screen row it was on. Expansion in each direction stops once it reaches a change
|
||||
// line (addition/deletion), which always survives a context-size change — so the list
|
||||
// always ends in a guaranteed survivor, while still offering the nearer context lines
|
||||
// as preferred candidates. The restore (see restoreDiffLinePositionOnRerender) lands
|
||||
// on the first of these the re-render still contains.
|
||||
func (self *StagingHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
|
||||
contents := view.DiffLineContents()
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
resolved := self.resolveDiffLines(contents)
|
||||
originY := view.OriginY()
|
||||
|
||||
var anchors []diffLineAnchor
|
||||
// collect appends the line at bufferLine if it resolves, and reports whether it's
|
||||
// a change line (a guaranteed survivor, so expansion past it isn't needed).
|
||||
collect := func(bufferLine int) (atChangeLine bool) {
|
||||
r := resolved[bufferLine]
|
||||
if !r.ok {
|
||||
return false
|
||||
}
|
||||
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
|
||||
anchors = append(anchors, diffLineAnchor{identity: r.info, row: viewLine - originY})
|
||||
}
|
||||
return r.info.IsChange()
|
||||
}
|
||||
|
||||
if collect(anchor) {
|
||||
return anchors
|
||||
}
|
||||
below, above := anchor+1, anchor-1
|
||||
for below < len(contents) || above >= 0 {
|
||||
if below < len(contents) {
|
||||
if collect(below) {
|
||||
below = len(contents) // stop expanding past the first change line below
|
||||
} else {
|
||||
below++
|
||||
}
|
||||
}
|
||||
if above >= 0 {
|
||||
if collect(above) {
|
||||
above = -1 // stop expanding past the first change line above
|
||||
} else {
|
||||
above--
|
||||
}
|
||||
}
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
// resolvedDiffLine is one line's resolved patch identity plus whether it could be
|
||||
// resolved, the element of the table resolveDiffLines produces.
|
||||
type resolvedDiffLine struct {
|
||||
info types.DiffLineInfo
|
||||
ok bool
|
||||
}
|
||||
|
||||
// resolveDiffLines recovers the patch-space identity of every line of a diff's
|
||||
// per-line content (see gocui.DiffLineContent) in one pass, indexed 1:1 with
|
||||
// contents. It is the batch form of the inverse diff-line primitive, used by the
|
||||
// whole-buffer scans — the position restore (nearbyDiffLines / Apply) and the
|
||||
// focused main view's navigation. Resolving line-by-line would re-run the buffer-
|
||||
// parse backend's whole-section parse once per line (O(n²) on a large single-file
|
||||
// diff); this parses the buffer-parse backend once for the whole buffer
|
||||
// (parseAllDiffLinesFromBuffer) and applies the per-line backend precedence
|
||||
// (resolveDiffLine) on top.
|
||||
func (self *StagingHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
|
||||
bufferParsed := parseAllDiffLinesFromBuffer(diffLineTexts(contents))
|
||||
resolved := make([]resolvedDiffLine, len(contents))
|
||||
for i, content := range contents {
|
||||
info, ok := self.resolveDiffLine(content, bufferParsed[i])
|
||||
resolved[i] = resolvedDiffLine{info, ok}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// diffLineInfoFromContents recovers the patch-space identity of a single buffer
|
||||
// line at idx (see resolveDiffLine for the backend precedence). It is the forward
|
||||
// consumers' resolver (click/enter/edit/PR, via GetDiffLineInfo on the displayed
|
||||
// view); to resolve every line of a buffer, use resolveDiffLines, which parses the
|
||||
// buffer-parse backend once rather than once per line.
|
||||
func (self *StagingHelper) diffLineInfoFromContents(contents []gocui.DiffLineContent, idx int) (types.DiffLineInfo, bool) {
|
||||
if idx < 0 || idx >= len(contents) {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), idx)
|
||||
return self.resolveDiffLine(contents[idx], bufferLineParse{parsed, ok})
|
||||
}
|
||||
|
||||
// resolveDiffLine applies the diff-line backends to one rendered row in order of
|
||||
// fidelity, given the row's content and the buffer-parse backend's already-computed
|
||||
// result for it:
|
||||
//
|
||||
// - mechanism #2: per-line OSC metadata emitted by a patched pager (delta),
|
||||
// which carries the side directly and so serves the renderings #1 can't parse
|
||||
// (delta's default mode, --line-numbers, diff-so-fancy);
|
||||
// - mechanism #1: parsing the decolorized buffer (bufferParsed), which serves
|
||||
// structure-preserving renderings (no pager, git diff --color, delta --color-only);
|
||||
// - delta's lazygit-edit:// hyperlinks; the hyperlink can't convey the side, so
|
||||
// its result is reported as a non-deletion content line.
|
||||
func (self *StagingHelper) resolveDiffLine(content gocui.DiffLineContent, bufferParsed bufferLineParse) (types.DiffLineInfo, bool) {
|
||||
if info, ok := self.diffLineInfoFromMetadata(content.Metadata); ok {
|
||||
return info, true
|
||||
}
|
||||
if bufferParsed.ok {
|
||||
return self.diffLineInfoFromParsed(bufferParsed.parsed), true
|
||||
}
|
||||
return self.diffLineInfoFromHyperlink(content.Hyperlink)
|
||||
}
|
||||
|
||||
// diffLineInfoPerRow resolves a single rendered row using only the backends that can
|
||||
// place it from material carried on the row itself — OSC metadata or a lazygit-edit
|
||||
// hyperlink. It omits the buffer-parse backend, which needs the surrounding
|
||||
// file/hunk lines and so can't resolve a row while the diff is still streaming in;
|
||||
// the incremental restore scan uses this, and resolves the buffer-parse case once
|
||||
// the diff is complete (see restoreDiffLinePositionOnRerender's Apply).
|
||||
func (self *StagingHelper) diffLineInfoPerRow(content gocui.DiffLineContent) (types.DiffLineInfo, bool) {
|
||||
if info, ok := self.diffLineInfoFromMetadata(content.Metadata); ok {
|
||||
return info, true
|
||||
}
|
||||
return self.diffLineInfoFromHyperlink(content.Hyperlink)
|
||||
}
|
||||
|
||||
// diffLineTexts extracts the decolorized text of each row — the input the
|
||||
// buffer-parse backend (mechanism #1) works on.
|
||||
func diffLineTexts(contents []gocui.DiffLineContent) []string {
|
||||
texts := make([]string, len(contents))
|
||||
for i, content := range contents {
|
||||
texts[i] = content.Text
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// diffLineInfoFromMetadata reads mechanism #2's per-line OSC metadata. The
|
||||
// payload is positional and ';'-delimited — version;type;new-line;old-line;file
|
||||
// — with the file last so it may itself contain ';', and old-line empty unless
|
||||
// the line is a deletion. See diff-line-metadata-notes.md §9.2.
|
||||
func (self *StagingHelper) diffLineInfoFromMetadata(payload string) (types.DiffLineInfo, bool) {
|
||||
if payload == "" {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
parsed, ok := parseDiffLineMetadata(payload)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
// The pager may emit an absolute or a repo-relative path (whichever is
|
||||
// convenient for it); normalize to the absolute path the consumers expect.
|
||||
path := parsed.RelPath
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
Path: path,
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}, true
|
||||
}
|
||||
|
||||
// diffLineInfoFromParsed turns the buffer parser's repo-relative result into the
|
||||
// absolute-path identity the consumers expect (mechanism #1).
|
||||
func (self *StagingHelper) diffLineInfoFromParsed(parsed parsedDiffLine) types.DiffLineInfo {
|
||||
return types.DiffLineInfo{
|
||||
Path: filepath.Join(self.c.Git().RepoPaths.WorktreePath(), parsed.RelPath),
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshInclusionGutter updates the focused main view's inclusion gutter to reflect
|
||||
// the custom patch being built. The gutter — a marker in a reserved left column on
|
||||
// every change line currently in the patch — is shown only while the main view holds
|
||||
// focus AND the panel beneath it builds a patch (commits / commit files) with a patch
|
||||
// active; otherwise it's hidden and the diff renders flush left. It is a focused-main-
|
||||
// view affordance: browsing the side panel, where the main view shows a preview, shows
|
||||
// no markers, and it tracks the secondary patch view's visibility (a patch that's
|
||||
// active but momentarily empty still shows the gutter). Call it whenever focus, the
|
||||
// patch membership, or the displayed content changes. It only repaints the gutter over
|
||||
// the existing content — the diff is unaffected by patch membership, so it never
|
||||
// re-renders.
|
||||
func (self *StagingHelper) RefreshInclusionGutter() {
|
||||
// The gutter is painted on the Normal (commit-diff) pane, but it's a focused-main-view
|
||||
// affordance of the whole pair: it stays visible while either pane holds focus, so it
|
||||
// doesn't vanish when you tab to the secondary custom-patch pane.
|
||||
v := self.c.Contexts().Normal.GetView()
|
||||
|
||||
// Check focus first: the gutter only shows while the focused main view holds focus, and
|
||||
// NextInStack below requires the context to be in the stack — which it is exactly when
|
||||
// it's the current one. The side panel is found beneath whichever pane is current.
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
current := self.c.Context().CurrentStatic()
|
||||
focused := current.GetKey() == self.c.Contexts().Normal.GetKey() ||
|
||||
current.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
|
||||
if !focused || !patchBuilder.Active() {
|
||||
v.SetInclusionGutter(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
sidePanel := self.c.Context().NextInStack(current)
|
||||
diffMainView, ok := sidePanel.(types.DiffMainViewContext)
|
||||
if !ok || diffMainView.GetDiffMainViewType() != types.DiffMainViewTypePatchBuilding {
|
||||
v.SetInclusionGutter(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(v.DiffLineContents())
|
||||
|
||||
// The patch builder keys files by their repo-relative path; gather each shown
|
||||
// file's included change-line identities once, keyed by the absolute path the
|
||||
// resolved rows carry.
|
||||
includedByPath := map[string]map[patch.LineIdentity]bool{}
|
||||
includedFor := func(absPath string) map[patch.LineIdentity]bool {
|
||||
if existing, ok := includedByPath[absPath]; ok {
|
||||
return existing
|
||||
}
|
||||
identities := map[patch.LineIdentity]bool{}
|
||||
if relPath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), absPath); err == nil {
|
||||
for _, id := range patchBuilder.IncludedLineIdentities(filepath.ToSlash(relPath)) {
|
||||
identities[id] = true
|
||||
}
|
||||
}
|
||||
includedByPath[absPath] = identities
|
||||
return identities
|
||||
}
|
||||
|
||||
marks := make([]bool, len(resolved))
|
||||
for i, r := range resolved {
|
||||
if !r.ok || !r.info.IsChange() {
|
||||
continue
|
||||
}
|
||||
lineNumber, isDeletion := r.info.PatchSelectLine()
|
||||
marks[i] = includedFor(r.info.Path)[patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion}]
|
||||
}
|
||||
v.SetInclusionGutter(true, marks)
|
||||
}
|
||||
|
||||
func (self *StagingHelper) diffLineInfoFromHyperlink(hyperlink string) (types.DiffLineInfo, bool) {
|
||||
matches := lazygitEditURLRegexp.FindStringSubmatch(hyperlink)
|
||||
if matches == nil {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
// delta emits an absolute path here, which is what the consumers want.
|
||||
Path: matches[1],
|
||||
// The hyperlink carries no side, so it can't distinguish a deletion from
|
||||
// an addition or context line; report it as a plain content line.
|
||||
Type: types.DiffLineOther,
|
||||
NewLine: utils.MustConvertToInt(matches[2]),
|
||||
}, true
|
||||
}
|
||||
|
||||
// --- The raw-diff fallback for unsupported diff renderers ---
|
||||
//
|
||||
// The focused main view is the staging surface, so it must be able to resolve the
|
||||
// diff it shows to patch-space. A renderer that restructures the diff without emitting
|
||||
// our metadata (stock delta-default, plain difftastic, `cat -n`) produces output we
|
||||
// can't resolve, so when such a diff is focused we re-render it raw (git's own
|
||||
// colour, no renderer) — the same content a no-renderer setup shows, which the buffer
|
||||
// parser handles. Browsing keeps the pretty output; only focusing to act switches to
|
||||
// raw.
|
||||
//
|
||||
// This is not only about pagers: git's own word-diff formats (a rawGit renderer whose
|
||||
// arguments include --color-words or --word-diff) restructure the diff just as much,
|
||||
// their markup being inline, and a git that speaks the protocol annotates them for
|
||||
// exactly that reason. So the question is asked of whatever renders, git included.
|
||||
//
|
||||
// Whether a renderer is usable is decided by probing it (see
|
||||
// DiffCommands.ProbePagerEmitsDiffMetadata) — a renderer-level, content-independent
|
||||
// fact, so the verdict is stable (a binary file, which has no change lines under any
|
||||
// renderer, can't mislead it) and known before we render, so we never render pretty
|
||||
// only to discover mid-flight that we should have rendered raw.
|
||||
|
||||
// pagerSupportsMetadata returns whether the current pager speaks the metadata
|
||||
// protocol, probing it once and caching the result, re-probing when the pager changes.
|
||||
func (self *StagingHelper) pagerSupportsMetadata() bool {
|
||||
sig := self.currentPagerSignature()
|
||||
if self.pagerMetadataSupport == nil || sig != self.pagerSupportSig {
|
||||
result := self.c.Git().Diff.ProbePagerEmitsDiffMetadata()
|
||||
self.pagerMetadataSupport = &result
|
||||
self.pagerSupportSig = sig
|
||||
}
|
||||
return *self.pagerMetadataSupport
|
||||
}
|
||||
|
||||
// currentPagerSignature identifies the current pager, so the cached verdict resets
|
||||
// when it changes (the user cycles pagers, or reloads a changed config). The pager
|
||||
// command's width placeholder is irrelevant to its identity, so a fixed width is used.
|
||||
func (self *StagingHelper) currentPagerSignature() string {
|
||||
pc := self.c.State().GetDiffRendererConfigManager()
|
||||
index, _ := pc.CurrentDiffRendererIndex()
|
||||
return fmt.Sprintf("%d\x00%s\x00%s\x00%s",
|
||||
index, pc.GetExternalDiffCommand(3), pc.GetStdinFilterCommand(0),
|
||||
strings.Join(pc.GetRawGitArgs(), "\x00"))
|
||||
}
|
||||
|
||||
// MainViewPagerConfigured reports whether any custom pager is in effect for the
|
||||
// focused main view's diff — an external diff command, git's diff.external config,
|
||||
// or a stdin pager. With no pager the diff is already raw and resolvable, so the
|
||||
// raw-diff fallback only applies when one is configured.
|
||||
func (self *StagingHelper) MainViewPagerConfigured() bool {
|
||||
pc := self.c.State().GetDiffRendererConfigManager()
|
||||
return pc.GetDiffRendererType() != config.DiffRendererType_RawGit
|
||||
}
|
||||
|
||||
func (self *StagingHelper) rendererIsRawGitWithArgs() bool {
|
||||
pc := self.c.State().GetDiffRendererConfigManager()
|
||||
return pc.GetDiffRendererType() == config.DiffRendererType_RawGit &&
|
||||
len(pc.GetRawGitArgs()) > 0
|
||||
}
|
||||
|
||||
// mainViewDiffNeedsMetadata reports whether the diff shown in the main view is one we
|
||||
// can't recover the patch-space identity of a row from by reading the diff's own text,
|
||||
// and so can only act on through the metadata records. Any custom renderer restructures
|
||||
// the diff; so does git itself once the renderer's arguments select a word diff, whose
|
||||
// markup is inline. Plain `git diff` output is its own answer and needs no records.
|
||||
func (self *StagingHelper) mainViewDiffNeedsMetadata() bool {
|
||||
return self.MainViewPagerConfigured() || self.rendererIsRawGitWithArgs()
|
||||
}
|
||||
|
||||
// DiffMainViewShouldRenderRaw reports whether a side panel rendering its diff into
|
||||
// the focused main view should bypass the diff renderer and render the raw
|
||||
// (git-coloured) diff. This holds while the main view holds focus and the diff we would
|
||||
// otherwise show needs the metadata records to be resolvable but won't carry them.
|
||||
// Side panels consult it when building their main-view diff (see e.g.
|
||||
// FilesController.GetOnRenderToMain) so that a re-render while focused — after staging
|
||||
// a hunk, say — stays raw.
|
||||
func (self *StagingHelper) DiffMainViewShouldRenderRaw() bool {
|
||||
return self.focusedOnMainView() && self.mainViewDiffNeedsMetadata() &&
|
||||
!self.pagerSupportsMetadata()
|
||||
}
|
||||
|
||||
func (self *StagingHelper) focusedOnMainView() bool {
|
||||
current := self.c.Context().CurrentStatic().GetKey()
|
||||
return current == self.c.Contexts().Normal.GetKey() ||
|
||||
current == self.c.Contexts().NormalSecondary.GetKey()
|
||||
}
|
||||
|
||||
// RenderFocusedMainViewRaw installs a restore that calls place once the focused main
|
||||
// view's raw re-render lands, then triggers that re-render via the side panel beneath.
|
||||
// It's used when focusing a diff under a pager that doesn't speak the protocol:
|
||||
// DiffMainViewShouldRenderRaw is already true (the verdict is probed, not derived from
|
||||
// this render), so the side panel renders raw; place then establishes the selection on
|
||||
// the result.
|
||||
func (self *StagingHelper) RenderFocusedMainViewRaw(view *gocui.View, sidePanel types.Context, place func()) {
|
||||
manager := self.c.GetOrCreateViewBufferManagerForView(view)
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
|
||||
manager.SetRestoreForNextTask(&tasks.RenderRestore{
|
||||
// Hold the first paint until end of input so the whole diff is read before we
|
||||
// place the selection (a change line near the end would otherwise be missed).
|
||||
FirstPaintReady: func() bool { return false },
|
||||
Apply: func(swapIn func()) {
|
||||
swapIn()
|
||||
manager.ClearRestoreForNextTask()
|
||||
// Apply runs on the task's goroutine; hop to the UI thread to place the
|
||||
// selection, which reads the swapped-in content and touches view state.
|
||||
self.c.OnUIThread(func() error {
|
||||
place()
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if sidePanel != nil {
|
||||
sidePanel.HandleRenderToMain()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,9 +703,21 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
||||
self.c.Tr.ExecCommandHere + "\n\n" + commit.Name)
|
||||
} else {
|
||||
refRange := self.context().GetSelectedRefRangeForDiffFiles()
|
||||
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange, renderRaw)
|
||||
}
|
||||
|
||||
// Keep the inclusion gutter in step with the content as this diff
|
||||
// (re-)renders. It's a no-op unless the main view is focused and a patch is
|
||||
// being built from this panel; a patch toggle re-renders this same diff, so
|
||||
// the marks recomputed over the current content stay valid through the swap.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// If this re-render is a commit rewrite under the focused main view (drop,
|
||||
// move-patch-out, undo, …), keep the selection on a surviving change rather
|
||||
// than leaving the stale range painted.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
@@ -720,16 +732,35 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
||||
}
|
||||
|
||||
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
|
||||
if c.Git().Patch.PatchBuilder.Active() {
|
||||
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
|
||||
patchBuilder := c.Git().Patch.PatchBuilder
|
||||
if !patchBuilder.Active() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bring the patch's diff trees up to date if the patch changed since they were last
|
||||
// built (a no-op on a plain re-render, e.g. navigating commits).
|
||||
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
if patchBuilder.TempDir() == "" {
|
||||
// The patch wasn't materialized (its temp dir couldn't be created); fall back to
|
||||
// rendering the aggregated patch directly with git's own colours.
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewRenderStringWithoutScrollTask(patch),
|
||||
Task: types.NewRenderStringWithoutScrollTask(patchBuilder.RenderAggregatedPatch(false)),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// Render the custom patch the same way the main view renders its diff — through the
|
||||
// pager, or raw (git's own colour) under the raw-diff fallback / when no pager is
|
||||
// configured — by re-diffing the two file trees the patch was materialized into.
|
||||
renderRaw := c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(patchBuilder.TempDir(), renderRaw)
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd()),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// primaryPatchActionFromFocusedMainView routes the primary action (space) in a commit
|
||||
// panel's focused main view by which pane it was pressed in: the main pane toggles the
|
||||
// selection into or out of the custom patch, while the secondary pane — which previews the
|
||||
// patch being built — removes the selection from the patch (mirroring how space in the
|
||||
// staging view's staged pane unstages). Both commit-panel controllers go through here so
|
||||
// the routing lives in one place.
|
||||
func primaryPatchActionFromFocusedMainView(
|
||||
c *ControllerCommon,
|
||||
mainViewName string,
|
||||
firstLineIdx int,
|
||||
lastLineIdx int,
|
||||
from string,
|
||||
to string,
|
||||
reverse bool,
|
||||
canRebase bool,
|
||||
refresh func(),
|
||||
) error {
|
||||
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
|
||||
return removePatchLinesFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx, refresh)
|
||||
}
|
||||
return togglePatchFromFocusedMainView(c, mainViewName, firstLineIdx, lastLineIdx,
|
||||
from, to, reverse, canRebase, refresh)
|
||||
}
|
||||
|
||||
// removePatchLinesFromFocusedMainView removes the selected change line(s) from the custom
|
||||
// patch when space is pressed in the secondary pane (the custom-patch preview). Every line
|
||||
// shown there is already in the patch, so this only ever removes — unlike the main pane's
|
||||
// toggle. The selection is mapped to patch-line indices by its ordinal among the shown
|
||||
// change lines (see ChangeLineOrdinalsInViewRange / IncludedChangeLineIndices) rather than
|
||||
// by line number, which the aggregated patch renumbers (so matching additions by number is
|
||||
// unreliable). Refresh then re-renders the smaller patch and the selection is re-established
|
||||
// by its change-line ordinal, like the toggle path.
|
||||
func removePatchLinesFromFocusedMainView(
|
||||
c *ControllerCommon,
|
||||
mainViewName string,
|
||||
firstLineIdx int,
|
||||
lastLineIdx int,
|
||||
refresh func(),
|
||||
) error {
|
||||
ordinalsByFile := c.Helpers().Staging.ChangeLineOrdinalsInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if len(ordinalsByFile) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := c.Git().Patch.PatchBuilder
|
||||
for absPath, ordinals := range ordinalsByFile {
|
||||
filename := patchFilename(c, absPath)
|
||||
if filename == "" {
|
||||
continue
|
||||
}
|
||||
included := patchBuilder.IncludedChangeLineIndices(filename)
|
||||
indices := make([]int, 0, len(ordinals))
|
||||
for _, ordinal := range ordinals {
|
||||
if ordinal >= 0 && ordinal < len(included) {
|
||||
indices = append(indices, included[ordinal])
|
||||
}
|
||||
}
|
||||
if len(indices) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := patchBuilder.RemoveFileLineRange(filename, "", indices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
refresh()
|
||||
// A removal shrinks the patch shown in the secondary, so the removed lines are consumed
|
||||
// from its diff; preserving the ordinal (advanceBy 0) lands on the next surviving line,
|
||||
// like unstaging.
|
||||
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// togglePatchFromFocusedMainView toggles the selected diff line(s) — a single line, a
|
||||
// range, or a hunk — into or out of the custom patch being built for (from, to,
|
||||
// reverse). It is the patch-building counterpart of the files panel's staging handler,
|
||||
// shared by the panels that build a patch from a focused main view: the commit files
|
||||
// panel (the per-file diff) and the commits / sub-commits / stash panels (the
|
||||
// whole-commit diff). The selection is resolved to change-line identities (shared with
|
||||
// staging) and mapped to the patch builder's per-file line indices.
|
||||
//
|
||||
// Building a patch needs the patch builder started for this target; if a patch for a
|
||||
// different target is active, we confirm before discarding it (as entering the patch
|
||||
// builder does). The diff itself is unchanged by a toggle, so refresh — supplied by the
|
||||
// caller, since what needs re-rendering differs per panel — re-renders the same diff
|
||||
// command (keeping its scroll) and the secondary patch view, and the inclusion gutter
|
||||
// rides that re-render. That re-render (and its layout re-wrap when the secondary view
|
||||
// first appears) is in view-line space, which moves the selection, so we re-establish it
|
||||
// afterwards by its width-independent change-line ordinal.
|
||||
func togglePatchFromFocusedMainView(
|
||||
c *ControllerCommon,
|
||||
mainViewName string,
|
||||
firstLineIdx int,
|
||||
lastLineIdx int,
|
||||
from string,
|
||||
to string,
|
||||
reverse bool,
|
||||
canRebase bool,
|
||||
refresh func(),
|
||||
) error {
|
||||
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := c.Git().Patch.PatchBuilder
|
||||
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, to, reverse)
|
||||
return c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: c.Tr.DiscardPatch,
|
||||
Prompt: c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
if !patchBuilder.Active() {
|
||||
patchBuilder.Start(from, to, reverse, canRebase)
|
||||
}
|
||||
|
||||
if err := togglePatchLines(c, infos); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refresh()
|
||||
// A toggle doesn't change the diff, so source and target are the same pane; the
|
||||
// re-render (and the layout re-wrap when the secondary view first appears) still
|
||||
// moves the selection in view-line space, so re-establish it — advanced past the
|
||||
// just-toggled change lines to the next stageable hunk/line, since they aren't
|
||||
// consumed (the diff is unchanged), unlike staging.
|
||||
revealSelectionAfterPrimaryAction(c, mainViewName, mainViewName, firstLineIdx, len(infos))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// togglePatchLines toggles the change lines identified by infos into or out of the
|
||||
// custom patch. The direction is decided once, from the first selected change line (in
|
||||
// view order) — already in the patch means remove the whole selection, otherwise add it
|
||||
// — and applied to every file the selection spans, mirroring how the patch explorer
|
||||
// toggles a range.
|
||||
func togglePatchLines(c *ControllerCommon, infos []types.DiffLineInfo) error {
|
||||
patchBuilder := c.Git().Patch.PatchBuilder
|
||||
|
||||
// Group the selected change lines by file (in view order), then resolve each file's
|
||||
// identities to patch-line indices in one pass.
|
||||
var order []string
|
||||
identitiesByFile := map[string][]patch.LineIdentity{}
|
||||
for _, info := range infos {
|
||||
filename := patchFilename(c, info.Path)
|
||||
if filename == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := identitiesByFile[filename]; !seen {
|
||||
order = append(order, filename)
|
||||
}
|
||||
lineNumber, isDeletion := info.PatchSelectLine()
|
||||
identitiesByFile[filename] = append(identitiesByFile[filename],
|
||||
patch.LineIdentity{LineNumber: lineNumber, IsDeletion: isDeletion})
|
||||
}
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
indicesByFile := map[string][]int{}
|
||||
for filename, identities := range identitiesByFile {
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines(filename, identities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indicesByFile[filename] = indices
|
||||
}
|
||||
|
||||
// Decide the direction from the first selected change line.
|
||||
firstFile := order[0]
|
||||
included, err := patchBuilder.GetFileIncLineIndices(firstFile, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remove := len(indicesByFile[firstFile]) > 0 && lo.Contains(included, indicesByFile[firstFile][0])
|
||||
toggle := patchBuilder.AddFileLineRange
|
||||
if remove {
|
||||
toggle = patchBuilder.RemoveFileLineRange
|
||||
}
|
||||
|
||||
for _, filename := range order {
|
||||
indices := indicesByFile[filename]
|
||||
if len(indices) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := toggle(filename, "", indices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardFromCommitDisabledReason reports why discarding lines from the commit shown in
|
||||
// the focused main view is unavailable, or nil when it's available. It mirrors the patch
|
||||
// builder's own guard: the diff must be a local commit's (canRebase), no rebase may be in
|
||||
// progress, and the diff context size must be non-zero (a patch can't be built from a
|
||||
// zero-context diff). Stash and sub-commits of another branch are never rebaseable, so
|
||||
// they always get the local-commits reason.
|
||||
//
|
||||
// Discard is never offered in the secondary pane: it previews the custom patch, so
|
||||
// discarding "from the commit" there would act on lines shown only as the patch — and
|
||||
// space already removes them from the patch — which would be confusing.
|
||||
func discardFromCommitDisabledReason(c *ControllerCommon, mainViewName string, canRebase bool) *types.DisabledReason {
|
||||
if mainViewName == c.Contexts().NormalSecondary.GetViewName() {
|
||||
return &types.DisabledReason{Text: c.Tr.CannotDiscardFromCustomPatchView}
|
||||
}
|
||||
if !canRebase {
|
||||
return &types.DisabledReason{Text: c.Tr.CanOnlyDiscardFromLocalCommits}
|
||||
}
|
||||
if c.Git().Status.WorkingTreeState().Any() {
|
||||
return &types.DisabledReason{Text: c.Tr.CantPatchWhileRebasingError}
|
||||
}
|
||||
if c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return &types.DisabledReason{Text: fmt.Sprintf(c.Tr.Actions.NotEnoughContextToRemoveLines,
|
||||
c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardSelectionFromCommit discards the selected diff line(s) — a single line, a
|
||||
// range, or a hunk — from the commit whose diff the focused main view shows, by building
|
||||
// a one-off patch from the selection and removing it from the commit via a rebase. It is
|
||||
// the patch-building counterpart of the working-tree discard the files panel does, and
|
||||
// mirrors the patch builder's own "discard lines from commit": any in-progress custom
|
||||
// patch is reset first (the confirm prompt warns when one exists), then a fresh patch
|
||||
// holding exactly the selection is built for (from, to, reverse) and removed from the
|
||||
// commit. The caller has already established (via canRebase) that the commit is on a
|
||||
// local branch; the target commit is the one identified by `to`.
|
||||
func discardSelectionFromCommit(
|
||||
c *ControllerCommon,
|
||||
mainViewName string,
|
||||
firstLineIdx int,
|
||||
lastLineIdx int,
|
||||
from string,
|
||||
to string,
|
||||
reverse bool,
|
||||
canRebase bool,
|
||||
) error {
|
||||
infos := c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx)
|
||||
if len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commitIndex := -1
|
||||
for i, commit := range c.Model().Commits {
|
||||
if commit.Hash() == to {
|
||||
commitIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if commitIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := c.Git().Patch.PatchBuilder
|
||||
prompt := lo.Ternary(patchBuilder.Active(),
|
||||
c.Tr.DiscardLinesFromCommitPromptWithReset,
|
||||
c.Tr.DiscardLinesFromCommitPrompt)
|
||||
|
||||
c.Confirm(types.ConfirmOpts{
|
||||
Title: c.Tr.DiscardLinesFromCommitTitle,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
// Build a fresh patch holding exactly the selection: reset any active patch,
|
||||
// then add the selected lines (togglePatchLines adds them, the patch being empty).
|
||||
if patchBuilder.Active() {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
patchBuilder.Start(from, to, reverse, canRebase)
|
||||
if err := togglePatchLines(c, infos); err != nil {
|
||||
return err
|
||||
}
|
||||
if patchBuilder.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
c.LogAction(c.Tr.Actions.RemovePatchFromCommit)
|
||||
err := c.Git().Patch.DeletePatchesFromCommit(c.Model().Commits, commitIndex)
|
||||
// The rebase rewrites the commit, so the focused main view's selection is
|
||||
// re-established on the next surviving change as the diff re-renders (see
|
||||
// preserveFocusedMainViewSelectionAcrossContentChange).
|
||||
return c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// patchFilename maps a diff line's absolute path to the key the patch builder stores
|
||||
// the file under — its repo-relative, slash-separated path.
|
||||
func patchFilename(c *ControllerCommon, absPath string) string {
|
||||
relPath, err := filepath.Rel(c.Git().RepoPaths.WorktreePath(), absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relPath)
|
||||
}
|
||||
@@ -151,9 +151,17 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO
|
||||
return self.withRenderAndFocus(self.HandleMouseDown)()
|
||||
}
|
||||
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if info, ok := self.c.Helpers().Staging.GetDiffLineInfo(self.context.GetWindowName(), opts.Y); ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
ClickedViewRealLineIdx: line,
|
||||
ClickedViewRealLineIsDeletion: isDeletion,
|
||||
})
|
||||
|
||||
return nil
|
||||
|
||||
@@ -45,9 +45,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))
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), renderRaw)
|
||||
|
||||
task = types.NewRunPtyTask(cmdObj.GetCmd())
|
||||
task = types.NewMainViewDiffTask(renderRaw, cmdObj.GetCmd())
|
||||
}
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -175,7 +176,7 @@ func (self *StagingController) Escape() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.c.Context().Pop()
|
||||
helpers.EscapeFromPatchExplorer(self.c.HelperCommon, self.c.Helpers().Staging, self.context)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,12 +93,23 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
task = types.NewRenderStringTask(self.c.Tr.NoStashEntries)
|
||||
} else {
|
||||
prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description())
|
||||
task = types.NewRunPtyTaskWithPrefix(
|
||||
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index).GetCmd(),
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
task = types.NewMainViewDiffTaskWithPrefix(
|
||||
renderRaw,
|
||||
self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, renderRaw).GetCmd(),
|
||||
prefix,
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the inclusion gutter in step with the content as this diff
|
||||
// (re-)renders; a no-op unless the main view is focused and a patch is being
|
||||
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a content change. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
@@ -106,6 +117,7 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,9 +46,19 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
|
||||
task = types.NewRenderStringTask("No commits")
|
||||
} else {
|
||||
refRange := self.context().GetSelectedRefRangeForDiffFiles()
|
||||
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
|
||||
renderRaw := self.c.Helpers().Staging.DiffMainViewShouldRenderRaw()
|
||||
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange, renderRaw)
|
||||
}
|
||||
|
||||
// Keep the inclusion gutter in step with the content as this diff
|
||||
// (re-)renders; a no-op unless the main view is focused and a patch is being
|
||||
// built from this panel. See LocalCommitsController.GetOnRenderToMain.
|
||||
self.c.Helpers().Staging.RefreshInclusionGutter()
|
||||
|
||||
// Preserve the focused-main-view selection across a commit rewrite. See
|
||||
// LocalCommitsController.GetOnRenderToMain.
|
||||
preserveFocusedMainViewSelectionAcrossContentChange(self.c, task)
|
||||
|
||||
self.c.RenderToMainViews(types.RefreshMainOpts{
|
||||
Pair: self.c.MainViewPairs().Normal,
|
||||
Main: &types.ViewUpdateOpts{
|
||||
@@ -56,6 +66,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,7 +123,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, nil)
|
||||
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, false, nil)
|
||||
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -50,6 +53,49 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) GetFocusedMainViewActions() types.FocusedMainViewActions {
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) OnClick(mainViewName string, clickedLineIdx int) error {
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
line, isDeletion := info.PatchSelectLine()
|
||||
|
||||
// Capture before self.enter() pushes the commit files panel, which
|
||||
// re-renders the main view. We escape "all the way out" to this side
|
||||
// panel (skipping the commit files panel), then focus the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context)
|
||||
|
||||
if err := self.enter(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
context := self.c.Contexts().CommitFiles
|
||||
var node *filetree.CommitFileNode
|
||||
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath = "./" + relativePath
|
||||
context.CommitFileTreeViewModel.ExpandToPath(relativePath)
|
||||
self.c.PostRefreshUpdate(context)
|
||||
|
||||
idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
context.SetSelectedLineIdx(idx)
|
||||
context.GetViewTrait().FocusPoint(
|
||||
context.ModelIndexToViewIndex(idx), false)
|
||||
node = context.GetSelected()
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) Context() types.Context {
|
||||
return self.context
|
||||
}
|
||||
@@ -69,16 +115,7 @@ func (self *SwitchToDiffFilesController) enter() error {
|
||||
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)
|
||||
@@ -106,6 +143,79 @@ func (self *SwitchToDiffFilesController) enter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// canRebase reports whether patches built from the selected ref may modify commits —
|
||||
// true only for commits of the currently checked-out branch, and not while diffing a
|
||||
// different ref or over a range. Shared by entering the commit files panel and toggling
|
||||
// patch lines straight from the main view.
|
||||
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refsRange *types.RefRange) bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
return canRebase
|
||||
}
|
||||
|
||||
// PrimaryAction toggles the selected line(s) of the whole-commit diff into or out of the
|
||||
// custom patch when space is pressed in the focused main view of the commits / sub-commits
|
||||
// / stash panels. The patch target is the panel's selected ref (or range), matching the
|
||||
// diff the main view shows. Unlike the commit files panel there are no per-file patch
|
||||
// indicators to update, so the toggle refreshes cheaply: it re-renders just this panel's
|
||||
// main + secondary views (leaving the commit list untouched, which a list refresh would
|
||||
// needlessly reload on every keystroke), re-running the same diff command (scroll
|
||||
// preserved) and repainting the inclusion gutter.
|
||||
func (self *SwitchToDiffFilesController) PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
ref := self.context.GetSelectedRef()
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
|
||||
from, to := context.FromAndToForDiff(ref, refsRange)
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
canRebase := self.canRebase(ref, refsRange)
|
||||
|
||||
return primaryPatchActionFromFocusedMainView(self.c, mainViewName, firstLineIdx, lastLineIdx,
|
||||
from, to, reverse, canRebase,
|
||||
func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.PostRefreshUpdate(self.context)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardSelection removes the selected line(s) of the whole-commit diff from the commit,
|
||||
// via a rebase. Same target derivation as the patch toggle; only enabled for local
|
||||
// commits (see DiscardSelectionDisabledReason), so stash and other-branch sub-commits
|
||||
// don't reach the rebase.
|
||||
func (self *SwitchToDiffFilesController) DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error {
|
||||
ref := self.context.GetSelectedRef()
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
|
||||
from, to := context.FromAndToForDiff(ref, refsRange)
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
canRebase := self.canRebase(ref, refsRange)
|
||||
|
||||
return discardSelectionFromCommit(self.c, mainViewName, firstLineIdx, lastLineIdx, from, to, reverse, canRebase)
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) DiscardSelectionDisabledReason(mainViewName string) *types.DisabledReason {
|
||||
canRebase := false
|
||||
if ref := self.context.GetSelectedRef(); ref != nil {
|
||||
canRebase = self.canRebase(ref, self.context.GetSelectedRefRangeForDiffFiles())
|
||||
}
|
||||
return discardFromCommitDisabledReason(self.c, mainViewName, canRebase)
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
|
||||
refRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
if refRange != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -61,21 +62,38 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary)
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
// Focusing by keyboard doesn't point at any particular line: in a diff view
|
||||
// we start at the first change block (like entering the staging view), in a
|
||||
// non-diff view we show no selection and the user just scrolls. Clicking does
|
||||
// point at a line, so it selects that line instead (focusMainView's
|
||||
// clickedLineIdx).
|
||||
return self.focusMainView(self.c.Contexts().Normal, -1)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
|
||||
if context, ok := mainViewContext.(types.ISearchableContext); ok {
|
||||
context.ClearSearchString()
|
||||
}
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
|
||||
mainViewContext.ClearSearchString()
|
||||
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
|
||||
|
||||
if !sidePanelShowsDiff(self.context) {
|
||||
// Non-diff main content (e.g. a branch's commit log): focus only, no
|
||||
// selection, since there's nothing to act on.
|
||||
return nil
|
||||
}
|
||||
|
||||
// A click points at a specific line (clickedLineIdx); keyboard focus (-1) starts at
|
||||
// the first change block. Either way, if the pager produced an unresolvable diff
|
||||
// this re-renders it raw first (see establishFocusedDiffSelection).
|
||||
establishFocusedDiffSelection(self.c, mainViewContext, clickedLineIdx)
|
||||
|
||||
// The inclusion gutter is refreshed by the main view's focus handler (it's shown
|
||||
// only while focused, so it tracks focus changes there).
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
type ViewSelectionControllerFactory struct {
|
||||
c *ControllerCommon
|
||||
}
|
||||
|
||||
func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory {
|
||||
return &ViewSelectionControllerFactory{
|
||||
c: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController {
|
||||
return &ViewSelectionController{
|
||||
baseController: baseController{},
|
||||
c: self.c,
|
||||
context: context,
|
||||
}
|
||||
}
|
||||
|
||||
type ViewSelectionController struct {
|
||||
baseController
|
||||
c *ControllerCommon
|
||||
|
||||
context types.Context
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) Context() types.Context {
|
||||
return self.context
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
return []*types.Binding{
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
|
||||
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
return []*gocui.ViewMouseBinding{}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleLineChange(delta int) {
|
||||
v := self.Context().GetView()
|
||||
if delta < 0 {
|
||||
v.ScrollUp(-delta)
|
||||
} else {
|
||||
v.ScrollDown(delta)
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handlePrevLine() error {
|
||||
self.handleLineChange(-1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleNextLine() error {
|
||||
self.handleLineChange(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handlePrevPage() error {
|
||||
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleNextPage() error {
|
||||
self.handleLineChange(self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleGotoTop() error {
|
||||
v := self.Context().GetView()
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleGotoBottom() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
v := self.Context().GetView()
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -501,6 +501,7 @@ func (gui *Gui) onUserConfigLoaded() error {
|
||||
|
||||
gui.setColorScheme()
|
||||
gui.configureViewProperties()
|
||||
gui.applyCurrentPagerSelectionStyle()
|
||||
|
||||
gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return)
|
||||
gui.g.NextSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.NextMatch)
|
||||
|
||||
@@ -185,6 +185,10 @@ func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
|
||||
self.gui.readLinesToFillView(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) GetOrCreateViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager {
|
||||
return self.gui.getManager(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) State() types.IStateAccessor {
|
||||
return self.gui.stateAccessor
|
||||
}
|
||||
|
||||
+7
-1
@@ -88,7 +88,13 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||
if !view.CanScrollPastBottom {
|
||||
maxOriginY -= newHeight - 1
|
||||
}
|
||||
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY {
|
||||
// Don't scroll up while the view's content is still being loaded: its
|
||||
// height only reflects what has been read so far, so clamping to it now
|
||||
// would yank the view to the top even though more content is on the way
|
||||
// (e.g. when re-rendering a diff the user was scrolled into).
|
||||
manager := gui.getViewBufferManagerForView(view)
|
||||
stillLoading := manager != nil && manager.IsLoading()
|
||||
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading {
|
||||
view.ScrollUp(oldOriginY - maxOriginY)
|
||||
// the view might not have scrolled actually (if it was at the limit
|
||||
// already), so we need to check if it did
|
||||
|
||||
+14
-10
@@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
|
||||
}
|
||||
|
||||
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
// need to reset scroll positions of all other main views
|
||||
for _, pair := range gui.allMainContextPairs() {
|
||||
if pair.Main != opts.Pair.Main {
|
||||
pair.Main.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
|
||||
pair.Secondary.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
gui.moveMainContextPairToTop(opts.Pair)
|
||||
|
||||
if opts.Main != nil {
|
||||
@@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
opts.Pair.Secondary.GetView().Clear()
|
||||
}
|
||||
|
||||
// Reset the scroll positions of all the other main views. We do this after
|
||||
// moving this pair to the top (which copies the previously-shown view's
|
||||
// content into the now-visible one to avoid a blank frame): resetting first
|
||||
// would zero that source view's scroll before it gets copied, forcing the
|
||||
// placeholder to the top instead of leaving it where the screen already was.
|
||||
for _, pair := range gui.allMainContextPairs() {
|
||||
if pair.Main != opts.Pair.Main {
|
||||
pair.Main.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
|
||||
pair.Secondary.GetView().SetOrigin(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
gui.splitMainPanel(opts.Secondary != nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const (
|
||||
HUNK
|
||||
)
|
||||
|
||||
func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State {
|
||||
func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, selectedRealLineIsDeletion bool, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State {
|
||||
if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 {
|
||||
// if we're here then we can return the old state. If selectedLineIdx was not -1
|
||||
// then that would mean we were trying to click and potentially drag a range, which
|
||||
@@ -61,6 +61,22 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat
|
||||
|
||||
viewLineIndices, patchLineIndices := wrapPatchLines(diff, view)
|
||||
|
||||
if selectedRealLineIdx != -1 {
|
||||
// Look the source line number up in the freshly parsed patch. A deletion
|
||||
// is identified by its old-file line number (two consecutive deletions
|
||||
// share a new-file line number), everything else by its new-file one.
|
||||
// The result is a patch line index, but selectedLineIdx is in view-line
|
||||
// (wrapped) space, so convert it; without this the landing line is off by
|
||||
// the number of wrapped lines above it.
|
||||
var patchLineIdx int
|
||||
if selectedRealLineIsDeletion {
|
||||
patchLineIdx = patch.PatchLineForOldLineNumber(selectedRealLineIdx)
|
||||
} else {
|
||||
patchLineIdx = patch.PatchLineForLineNumber(selectedRealLineIdx)
|
||||
}
|
||||
selectedLineIdx = viewLineIndices[lo.Clamp(patchLineIdx, 0, len(viewLineIndices)-1)]
|
||||
}
|
||||
|
||||
rangeStartLineIdx := 0
|
||||
if oldState != nil {
|
||||
rangeStartLineIdx = oldState.rangeStartLineIdx
|
||||
@@ -76,14 +92,28 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat
|
||||
userEnabledHunkMode = oldState.userEnabledHunkMode
|
||||
}
|
||||
|
||||
// if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line
|
||||
// A non-negative line index means we were given a specific line to select:
|
||||
// either by clicking or pressing enter on a line in a focused main view, or
|
||||
// by clicking directly on the patch explorer view to focus it.
|
||||
if selectedLineIdx >= 0 {
|
||||
// Clamp to the number of wrapped view lines; index might be out of
|
||||
// bounds if a custom diff renderer is being used which produces more lines
|
||||
selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1)
|
||||
selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1)
|
||||
|
||||
selectMode = RANGE
|
||||
rangeStartLineIdx = selectedLineIdx
|
||||
if selectLineInDefaultMode {
|
||||
// Diving in from a focused main view: keep the default select mode
|
||||
// computed above. In hunk mode the selection covers the block of
|
||||
// changes around the line, so snap to a change line if the given one
|
||||
// is a context line (just as toggling hunk mode does).
|
||||
if selectMode == HUNK {
|
||||
selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(patchLineIndices[selectedLineIdx])]
|
||||
}
|
||||
} else {
|
||||
// Clicking directly on the view starts a range selection that can be
|
||||
// extended by dragging.
|
||||
selectMode = RANGE
|
||||
rangeStartLineIdx = selectedLineIdx
|
||||
}
|
||||
} else if oldState != nil {
|
||||
// if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default)
|
||||
if oldState.selectMode != RANGE {
|
||||
|
||||
@@ -230,6 +230,7 @@ M file1
|
||||
func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
patchBuilder.Start("from", "to", false, false)
|
||||
result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{})
|
||||
|
||||
@@ -65,6 +65,15 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
|
||||
|
||||
// Advertise the diff-line metadata protocol versions we understand, so that
|
||||
// whatever renders the diff annotates each line with an OSC sequence we can read
|
||||
// back (see diff-line-metadata-notes.md). Set before the no-pty path below,
|
||||
// because git renders the diff itself there and is one of the things that speaks
|
||||
// the protocol — for its word-diff formats, whose inline markup we could not
|
||||
// otherwise resolve. Anything that doesn't understand the variable ignores it, so
|
||||
// this is safe to set unconditionally.
|
||||
cmd.Env = append(cmd.Env, "OSC1717=V1")
|
||||
|
||||
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
|
||||
// If we're not using a custom diff renderer, then we don't need to use a pty
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
@@ -72,6 +81,16 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
|
||||
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
|
||||
|
||||
// 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
|
||||
// not-yet-loaded content.
|
||||
gui.getManager(view).StartLoading()
|
||||
// Hold the scrollbar at its current height while the re-render loads, so the
|
||||
// thumb doesn't shrink and snap back when the first partial paint swaps in
|
||||
// (see the matching call in newCmdTask).
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Run the pty after layout so that it gets the correct size
|
||||
gui.afterLayout(func() error {
|
||||
// Need to get the width and the pager command again because the layout might have
|
||||
@@ -137,6 +156,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
}
|
||||
|
||||
linesToRead := gui.linesToReadFromCmdTask(view)
|
||||
// As in newCmdTask: if a restore is pending for this content (returning to a
|
||||
// focused main view on escape), let the task re-establish the scroll
|
||||
// position and selection as it first paints, reading to end of input so a
|
||||
// deep target line is found and the scrollbar ends up accurate.
|
||||
restore := manager.GetRestoreForNextTask()
|
||||
if restore != nil {
|
||||
linesToRead.Restore = restore
|
||||
linesToRead.Total = -1
|
||||
}
|
||||
// New content scrolls back to the top at the first paint; same content keeps
|
||||
// its scroll, and a restore places the scroll itself. See LinesToRead.ResetOrigin.
|
||||
linesToRead.ResetOrigin = restore == nil && cmdStr != manager.GetTaskKey()
|
||||
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
).Debug("RunCommand")
|
||||
|
||||
manager := gui.getManager(view)
|
||||
// Mark the view as loading synchronously (before the task's goroutine runs
|
||||
// and before the next layout pass) so the layout doesn't clamp the scroll
|
||||
// position to the not-yet-loaded content.
|
||||
manager.StartLoading()
|
||||
// Hold the scrollbar at the height the view has now (the previous render),
|
||||
// while it still shows that render: once the re-render swaps in its first
|
||||
// partial paint the displayed buffer is briefly short, and we don't want the
|
||||
// thumb to shrink and snap back as the rest loads.
|
||||
view.FreezeScrollbarHeight()
|
||||
|
||||
// Snapshot the view width here, on the UI thread, so the task goroutine
|
||||
// doesn't read the view's live dimensions while it streams output. It's
|
||||
@@ -43,6 +52,19 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
}
|
||||
|
||||
linesToRead := gui.linesToReadFromCmdTask(view)
|
||||
// If a restore is pending for this content (returning to a focused main view
|
||||
// on escape), let the task re-establish the scroll position and selection as
|
||||
// it first paints. It also reads to end of input so a deep target line is
|
||||
// found and the scrollbar ends up accurate. See RenderRestore.
|
||||
restore := manager.GetRestoreForNextTask()
|
||||
if restore != nil {
|
||||
linesToRead.Restore = restore
|
||||
linesToRead.Total = -1
|
||||
}
|
||||
// New content (a different command than the view last showed) scrolls back to
|
||||
// the top, at the first paint; same content keeps its scroll, and a restore
|
||||
// places the scroll itself. See LinesToRead.ResetOrigin.
|
||||
linesToRead.ResetOrigin = restore == nil && cmdStr != manager.GetTaskKey()
|
||||
if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
}
|
||||
@@ -136,12 +158,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
gui.Log,
|
||||
view,
|
||||
func() {
|
||||
// we could clear here, but that actually has the effect of causing a flicker
|
||||
// where the view may contain no content momentarily as the gui refreshes.
|
||||
// Instead, we're rewinding the write pointer so that we will just start
|
||||
// overwriting the existing content from the top down. Once we've reached
|
||||
// the end of the content do display, we call view.FlushStaleCells() to
|
||||
// clear out the remaining content from the previous render.
|
||||
// Called before showing the "loading..." indicator: clear the
|
||||
// displayed buffer so only "loading..." is shown. The actual content
|
||||
// is rendered off-screen (beginRender below) and swapped in, so it
|
||||
// never overwrites the displayed buffer incrementally.
|
||||
view.Reset()
|
||||
},
|
||||
func() {
|
||||
@@ -153,6 +173,11 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
gui.renderContentOnly()
|
||||
},
|
||||
func() {
|
||||
// The content is fully loaded now, so let the scrollbar track it
|
||||
// directly again (it was held at the previous render's height while
|
||||
// loading, see FreezeScrollbarHeight).
|
||||
view.UnfreezeScrollbarHeight()
|
||||
|
||||
// Need to check if the content of the view is well past the origin.
|
||||
linesHeight := view.ViewLinesHeight()
|
||||
_, originY := view.Origin()
|
||||
@@ -167,6 +192,8 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
func() {
|
||||
view.SetOrigin(0, 0)
|
||||
},
|
||||
view.BeginOffscreenRender,
|
||||
view.SwapInOffscreenRender,
|
||||
func() gocui.Task {
|
||||
// A background task: rendering content into a view is display
|
||||
// work, not lazygit driving a git operation, so it must not
|
||||
|
||||
@@ -67,6 +67,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, creating it if the view
|
||||
// hasn't rendered yet. Used to install a render restore on a pane that is about
|
||||
// to appear (e.g. the secondary half when a stage/unstage splits the diff).
|
||||
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
|
||||
|
||||
+124
-8
@@ -95,9 +95,10 @@ type IBaseContext interface {
|
||||
// that the generic ListController can be specialized by view-specific controllers.
|
||||
// We'll need to think of a better way to do this.
|
||||
AddOnDoubleClickFn(func() error)
|
||||
// Likewise for the focused main view: we need this to communicate between a
|
||||
// side panel controller and the focused main view controller.
|
||||
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
|
||||
// Likewise for the focused main view: this is how a side panel controller exposes
|
||||
// the actions its diff supports there (diving in, staging, patch toggling, …) to
|
||||
// the focused main view controller. nil for panels with no such actions.
|
||||
AddFocusedMainViewActions(FocusedMainViewActions)
|
||||
// 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)
|
||||
@@ -174,6 +175,42 @@ type DiffableContext interface {
|
||||
RefForAdjustingLineNumberInDiff() string
|
||||
}
|
||||
|
||||
// DiffMainViewContext is implemented by the side panel contexts whose focused
|
||||
// main view shows a unified diff — files, local commits, sub-commits, reflog,
|
||||
// stash, and commit files — as opposed to a commit log or other non-diff content
|
||||
// (branches, tags, status, …). It is distinct from DiffableContext, which is
|
||||
// about producing a diff between two refs for the diff menu. Implementing it is
|
||||
// the signal for whether to show a selection in the focused main view: a selection
|
||||
// is only meaningful where there are diff lines to act on (stage, edit, jump by
|
||||
// hunk, open in a pull request). The returned type additionally classifies what the
|
||||
// primary action (space) does there.
|
||||
type DiffMainViewContext interface {
|
||||
Context
|
||||
|
||||
GetDiffMainViewType() DiffMainViewType
|
||||
}
|
||||
|
||||
// DiffMainViewType classifies what the primary action (space) does in a side panel's
|
||||
// focused main view — and, for DiffMainViewTypePatchBuilding, that the focused main
|
||||
// view shows the inclusion gutter (it marks which change lines are in the patch, so
|
||||
// it's meaningful only beneath a patch-building panel, and even then only while a
|
||||
// patch is active).
|
||||
type DiffMainViewType int
|
||||
|
||||
const (
|
||||
// DiffMainViewTypeNone: a diff is shown — so the focused main view still has a
|
||||
// selection to edit, jump by hunk, or open in a pull request — but the primary
|
||||
// action does nothing. Currently the reflog (building a patch from it is a deferred
|
||||
// gap).
|
||||
DiffMainViewTypeNone DiffMainViewType = iota
|
||||
// DiffMainViewTypeStaging: the primary action stages/unstages into the working tree
|
||||
// (the files panel).
|
||||
DiffMainViewTypeStaging
|
||||
// DiffMainViewTypePatchBuilding: the primary action toggles the selection into a
|
||||
// custom patch (the commit files / commits / sub-commits / stash panels).
|
||||
DiffMainViewTypePatchBuilding
|
||||
)
|
||||
|
||||
type IListContext interface {
|
||||
Context
|
||||
|
||||
@@ -205,6 +242,34 @@ type IPatchExplorerContext interface {
|
||||
NavigateTo(selectedLineIdx int)
|
||||
GetMutex() *deadlock.Mutex
|
||||
IsPatchExplorerContext() // used for type switch
|
||||
|
||||
// See FocusedMainViewSnapshot. Nil unless this patch explorer was entered
|
||||
// from a focused main view.
|
||||
GetFocusedMainViewSnapshot() *FocusedMainViewSnapshot
|
||||
SetFocusedMainViewSnapshot(*FocusedMainViewSnapshot)
|
||||
}
|
||||
|
||||
// FocusedMainViewSnapshot records where a focused main view was when we dived
|
||||
// into a patch explorer (staging or patch building) from it, so that escaping
|
||||
// returns us to the same place with the main view focused again. It is nil when
|
||||
// the patch explorer was entered the normal way (through a side panel), in which
|
||||
// case escape just pops to that side panel.
|
||||
type FocusedMainViewSnapshot struct {
|
||||
// The side panel to land on first; pushing it re-renders the original
|
||||
// content into the main view. For commits/stash this is the originating side
|
||||
// panel (skipping the commit files panel we passed through), preserving the
|
||||
// pre-existing "escape all the way out" behavior.
|
||||
SidePanel Context
|
||||
// The side panel's selected line, to restore before re-rendering it. Diving
|
||||
// into staging can change the side panel's selection (e.g. from a directory
|
||||
// to a file in the files panel); restoring it makes the main view show the
|
||||
// same content again. -1 if the side panel isn't a list.
|
||||
SidePanelSelectedLineIdx int
|
||||
// The focused main view context to focus afterwards. Where in it to scroll to
|
||||
// and select is not captured here: on escape we land on the line the patch
|
||||
// explorer ended up selecting, found by its patch identity in the re-rendered
|
||||
// content, which survives the diff changing in a way a saved index wouldn't.
|
||||
MainView Context
|
||||
}
|
||||
|
||||
type IViewTrait interface {
|
||||
@@ -227,8 +292,28 @@ type IViewTrait interface {
|
||||
}
|
||||
|
||||
type OnFocusOpts struct {
|
||||
ClickedWindowName string
|
||||
ClickedViewLineIdx int
|
||||
ClickedWindowName string
|
||||
ClickedViewLineIdx int
|
||||
|
||||
// A source line number identifying the line to land on in the patch
|
||||
// explorer. If not -1, takes precedence over ClickedViewLineIdx. It is a
|
||||
// new-file line number, unless ClickedViewRealLineIsDeletion is set, in which
|
||||
// case it is an old-file line number (two consecutive deletions share a
|
||||
// new-file line number, so only the old-file number identifies a deletion).
|
||||
ClickedViewRealLineIdx int
|
||||
|
||||
// Whether ClickedViewRealLineIdx is an old-file line number for a deletion;
|
||||
// see above.
|
||||
ClickedViewRealLineIsDeletion bool
|
||||
|
||||
// When entering a patch explorer (staging or patch building) by clicking or
|
||||
// pressing enter on a line in a focused main view, we select that line using
|
||||
// the default select mode (hunk or line, per the UseHunkModeInStagingView
|
||||
// config), the same as when entering through the side panel. Clicking
|
||||
// directly on the patch explorer view instead starts a range selection that
|
||||
// can be extended by dragging.
|
||||
SelectLineInDefaultMode bool
|
||||
|
||||
ScrollSelectionIntoView bool
|
||||
}
|
||||
|
||||
@@ -263,9 +348,39 @@ type HasKeybindings interface {
|
||||
// decides not to do anything with the click.
|
||||
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
|
||||
|
||||
// Implement this in a side-panel controller to get called when there's a click in the main view
|
||||
// that belongs to your panel while the main view is already focused.
|
||||
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
|
||||
// Implement this in a side-panel controller to expose the actions its diff
|
||||
// supports when shown in the focused main view (see FocusedMainViewActions).
|
||||
// Return nil for a panel whose diff offers none.
|
||||
GetFocusedMainViewActions() FocusedMainViewActions
|
||||
}
|
||||
|
||||
// FocusedMainViewActions is the set of actions a side panel offers on its diff while
|
||||
// that diff is shown in the focused main view. The focused main view controller owns
|
||||
// the keybindings and the selection mechanics and dispatches to whichever panel is
|
||||
// beneath it; what each action means is the panel's business — the working-tree files
|
||||
// panel stages, the commit panels toggle the selection into a custom patch. The
|
||||
// inclusive view-line range passed to the action methods is the current selection (a
|
||||
// single line, a range, or a hunk). mainViewName identifies which of the two main
|
||||
// panes the user acted in.
|
||||
type FocusedMainViewActions interface {
|
||||
// OnClick dives into staging / patch-building for the clicked line, the same way a
|
||||
// click in the panel's own view does. Also used by enter (on the selected line).
|
||||
OnClick(mainViewName string, clickedLineIdx int) error
|
||||
|
||||
// PrimaryAction acts on the selected diff line(s) when the user presses space:
|
||||
// stage/unstage for the working-tree files panel, toggle into/out of the custom
|
||||
// patch for the commit panels. The handler re-renders the diff and re-establishes
|
||||
// the selection itself (staging can move the acted-on side to the other pane,
|
||||
// which the handler then focuses).
|
||||
PrimaryAction(mainViewName string, firstLineIdx int, lastLineIdx int) error
|
||||
|
||||
// DiscardSelection discards the selected diff line(s) when the user presses the
|
||||
// remove key: from the working tree for the files panel, or from the commit (via a
|
||||
// rebase) for the commit panels. DiscardSelectionDisabledReason reports why discard
|
||||
// is unavailable here — e.g. the diff isn't a local commit's, or a rebase is in
|
||||
// progress — or nil when it's available.
|
||||
DiscardSelection(mainViewName string, firstLineIdx int, lastLineIdx int) error
|
||||
DiscardSelectionDisabledReason(mainViewName string) *DisabledReason
|
||||
}
|
||||
|
||||
type IController interface {
|
||||
@@ -326,6 +441,7 @@ type IContextMgr interface {
|
||||
CurrentSide() Context
|
||||
CurrentPopup() []Context
|
||||
NextInStack(context Context) Context
|
||||
IsInStack(c Context) bool
|
||||
IsCurrent(c Context) bool
|
||||
IsCurrentOrParent(c Context) bool
|
||||
ForEach(func(Context))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package types
|
||||
|
||||
// DiffLineType classifies a rendered diff row. It mirrors the per-line "type"
|
||||
// of the metadata model described in diff-line-metadata-notes.md, so that the
|
||||
// host-side buffer parser (mechanism #1) and the future pager-emitted OSC
|
||||
// metadata (#2) produce the same shape.
|
||||
type DiffLineType int
|
||||
|
||||
const (
|
||||
DiffLineFileHeader DiffLineType = iota
|
||||
DiffLineHunkHeader
|
||||
DiffLineContext
|
||||
DiffLineAdded
|
||||
DiffLineDeleted
|
||||
// DiffLineOther is anything that isn't one of the above (e.g. the
|
||||
// "\ No newline at end of file" marker). It is also what a backend that
|
||||
// can't determine the side reports — delta's lazygit-edit hyperlinks carry
|
||||
// no side — so consumers treat it like a non-deletion content line.
|
||||
DiffLineOther
|
||||
)
|
||||
|
||||
// DiffLineInfo is the patch-space identity of a rendered diff row, as recovered
|
||||
// by StagingHelper.GetDiffLineInfo. It is the single shape the focused main view
|
||||
// and patch explorer consumers act on, regardless of which backend produced it.
|
||||
type DiffLineInfo struct {
|
||||
// Path is the absolute path of the file the line belongs to.
|
||||
Path string
|
||||
Type DiffLineType
|
||||
// NewLine is the line's position in the new file. Set for all content lines
|
||||
// (for a deletion it is the new-file position the deletion sits at) and for
|
||||
// hunk headers (the first line of the hunk they head). Not meaningful for
|
||||
// file headers: a pager's `f` record carries no line number (so the OSC
|
||||
// backend reports 0), and the buffer parser reports 1.
|
||||
NewLine int
|
||||
// OldLine is the line's position in the old file. Set only for deletions.
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// IsChange reports whether the row is an added or deleted line, as opposed to a
|
||||
// context line or a header. It mirrors patch.PatchLine.IsChange, and is how both
|
||||
// the context-change scroll preservation (anchor on a surviving change line) and
|
||||
// the focused main view's hunk navigation (a "hunk" is a block of consecutive
|
||||
// change lines) classify a rendered row.
|
||||
func (self DiffLineInfo) IsChange() bool {
|
||||
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
|
||||
}
|
||||
|
||||
// PatchSelectLine returns the source line to land on when diving into the patch
|
||||
// explorer for this row, in source-line-number space so it survives the patch
|
||||
// being regenerated. For a deletion it is the old-file line number — two
|
||||
// consecutive deletions share a new-file line number, so only the old-file
|
||||
// number tells them apart — and for everything else the new-file line number.
|
||||
func (self DiffLineInfo) PatchSelectLine() (lineNumber int, isDeletion bool) {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return self.OldLine, true
|
||||
}
|
||||
return self.NewLine, false
|
||||
}
|
||||
|
||||
// SamePatchLine reports whether two identities point at the same source line of
|
||||
// the same file. It is how the escape restore matches the line the patch explorer
|
||||
// had selected against the rows of the focused main view as it re-renders: the
|
||||
// comparison is in source-line-number space (PatchSelectLine), which survives the
|
||||
// diff being regenerated, rather than a fragile view-line index.
|
||||
//
|
||||
// A backend that can't determine the side (delta's lazygit-edit hyperlinks report
|
||||
// DiffLineOther) yields a non-deletion identity, so a deletion captured from a
|
||||
// full-fidelity backend won't match such a row — the restore then just doesn't
|
||||
// find its line, which is the acceptable degradation for that pager config.
|
||||
//
|
||||
// A header row shares its line number with a content row — a hunk header carries
|
||||
// the hunk's first line — so headers only match headers of the same kind:
|
||||
// otherwise a restore aiming at a hunk's first content line would land one row up
|
||||
// on the header above it (or vice versa).
|
||||
func (self DiffLineInfo) SamePatchLine(other DiffLineInfo) bool {
|
||||
if self.Path != other.Path {
|
||||
return false
|
||||
}
|
||||
if self.isHeader() != other.isHeader() {
|
||||
return false
|
||||
}
|
||||
if self.isHeader() && self.Type != other.Type {
|
||||
return false
|
||||
}
|
||||
selfLine, selfIsDeletion := self.PatchSelectLine()
|
||||
otherLine, otherIsDeletion := other.PatchSelectLine()
|
||||
return selfLine == otherLine && selfIsDeletion == otherIsDeletion
|
||||
}
|
||||
|
||||
func (self DiffLineInfo) isHeader() bool {
|
||||
return self.Type == DiffLineFileHeader || self.Type == DiffLineHunkHeader
|
||||
}
|
||||
|
||||
// PullRequestAnchor returns the side ("L"/"R") and line number to anchor a
|
||||
// GitHub PR deep-link at: the left/old side for a deletion, the right/new side
|
||||
// otherwise.
|
||||
func (self DiffLineInfo) PullRequestAnchor() (side string, lineNumber int) {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return "L", self.OldLine
|
||||
}
|
||||
return "R", self.NewLine
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSamePatchLine(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
a, b DiffLineInfo
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"content lines at the same new-file line match",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineAdded, NewLine: 10},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"different files don't match",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
|
||||
DiffLineInfo{Path: "bar", Type: DiffLineContext, NewLine: 10},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"a deletion doesn't match a non-deletion at the same position",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineDeleted, NewLine: 10, OldLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
|
||||
false,
|
||||
},
|
||||
// A hunk header carries the new-file line of the hunk's first line, so
|
||||
// it shares its number with that content line; the header/content guard
|
||||
// is what keeps a restore aiming at one from landing on the other.
|
||||
{
|
||||
"a hunk header doesn't match the hunk's first content line",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"a content line doesn't match a hunk header at its line",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineContext, NewLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"hunk headers of the same hunk match",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"hunk headers of different hunks don't match",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 10},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 25},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"file headers of the same file match",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
|
||||
true,
|
||||
},
|
||||
// A whole-file deletion's hunk header carries new-line 0, the same
|
||||
// number a file header reports; only the type tells them apart.
|
||||
{
|
||||
"a file header doesn't match a deleted file's hunk header",
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineFileHeader},
|
||||
DiffLineInfo{Path: "foo", Type: DiffLineHunkHeader, NewLine: 0},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, s.a.SamePatchLine(s.b))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,3 +98,26 @@ func NewRunPtyTask(cmd *exec.Cmd) *RunPtyTask {
|
||||
func NewRunPtyTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunPtyTask {
|
||||
return &RunPtyTask{Cmd: cmd, Prefix: prefix}
|
||||
}
|
||||
|
||||
// NewMainViewDiffTask builds the task for rendering a diff into the main view,
|
||||
// choosing between the normal pty task and the focused main view's raw-diff
|
||||
// fallback. When renderRaw is set (the focused main view needs to act on a diff the
|
||||
// configured pager can't resolve) it uses a plain command task, which — unlike the
|
||||
// pty task — doesn't pipe the diff through a stdin pager (GIT_PAGER); the external
|
||||
// diff command, if any, is suppressed in the cmd itself (its ignoreExternalDiff arg).
|
||||
// The caller passes the same renderRaw to the diff-cmd builder so the two stay in step.
|
||||
func NewMainViewDiffTask(renderRaw bool, cmd *exec.Cmd) UpdateTask {
|
||||
if renderRaw {
|
||||
return NewRunCommandTask(cmd)
|
||||
}
|
||||
return NewRunPtyTask(cmd)
|
||||
}
|
||||
|
||||
// NewMainViewDiffTaskWithPrefix is NewMainViewDiffTask for a diff rendered with a
|
||||
// leading prefix (e.g. a range-diff or stash header).
|
||||
func NewMainViewDiffTaskWithPrefix(renderRaw bool, cmd *exec.Cmd, prefix string) UpdateTask {
|
||||
if renderRaw {
|
||||
return NewRunCommandTaskWithPrefix(cmd, prefix)
|
||||
}
|
||||
return NewRunPtyTaskWithPrefix(cmd, prefix)
|
||||
}
|
||||
|
||||
@@ -106,6 +106,14 @@ func (gui *Gui) createAllViews() error {
|
||||
view.AutoRenderHyperLinks = true
|
||||
}
|
||||
|
||||
gui.applyCurrentPagerSelectionStyle()
|
||||
|
||||
// The on-demand inclusion gutter marker for custom-patch building.
|
||||
for _, view := range []*gocui.View{gui.Views.Main, gui.Views.Secondary} {
|
||||
view.InclusionGutterMarker = "✓"
|
||||
view.InclusionGutterMarkerColor = gocui.ColorGreen
|
||||
}
|
||||
|
||||
gui.Views.Staging.Wrap = true
|
||||
gui.Views.StagingSecondary.Wrap = true
|
||||
gui.Views.PatchBuilding.Wrap = true
|
||||
@@ -155,6 +163,17 @@ func (gui *Gui) createAllViews() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gui *Gui) applyCurrentPagerSelectionStyle() {
|
||||
bgColorWidth := 0
|
||||
if gui.stateAccessor != nil && gui.stateAccessor.GetDiffRendererConfigManager() != nil &&
|
||||
gui.stateAccessor.GetDiffRendererConfigManager().GetNarrowSelectionHighlight() {
|
||||
bgColorWidth = 2
|
||||
}
|
||||
|
||||
gui.Views.Main.SelectedLineBgColorWidth = bgColorWidth
|
||||
gui.Views.Secondary.SelectedLineBgColorWidth = bgColorWidth
|
||||
}
|
||||
|
||||
func (gui *Gui) configureViewProperties() {
|
||||
frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'}
|
||||
switch gui.c.UserConfig().Gui.Border {
|
||||
|
||||
+10
-2
@@ -396,6 +396,8 @@ type TranslationSet struct {
|
||||
AskQuestion string
|
||||
PrevHunk string
|
||||
NextHunk string
|
||||
PrevFile string
|
||||
NextFile string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -455,6 +457,7 @@ type TranslationSet struct {
|
||||
CheckoutCommitFileTooltip string
|
||||
CannotCheckoutWithModifiedFilesErr string
|
||||
CanOnlyDiscardFromLocalCommits string
|
||||
CannotDiscardFromCustomPatchView string
|
||||
CannotDiscardFromMultipleCommits string
|
||||
Remove string
|
||||
DiscardOldFileChangeTooltip string
|
||||
@@ -548,8 +551,10 @@ type TranslationSet struct {
|
||||
EmptyPatchError string
|
||||
EnterCommitFile string
|
||||
EnterCommitFileTooltip string
|
||||
EnterStaging string
|
||||
ExitCustomPatchBuilder string
|
||||
ExitFocusedMainView string
|
||||
ToggleSelectionInFocusedMainView string
|
||||
EnterUpstream string
|
||||
InvalidUpstream string
|
||||
NewRemote string
|
||||
@@ -834,7 +839,6 @@ type TranslationSet struct {
|
||||
SortOrderPrompt string
|
||||
SortCommits string
|
||||
SortCommitsTooltip string
|
||||
CantChangeContextSizeError string
|
||||
CantChangeRenameThresholdError string
|
||||
OpenCommitInBrowser string
|
||||
ViewBisectOptions string
|
||||
@@ -1559,6 +1563,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AskQuestion: "Ask Question",
|
||||
PrevHunk: "Go to previous hunk",
|
||||
NextHunk: "Go to next hunk",
|
||||
PrevFile: "Go to previous file",
|
||||
NextFile: "Go to next file",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
@@ -1618,6 +1624,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: "Cannot discard from the custom patch view; press space to remove lines from the patch instead",
|
||||
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.",
|
||||
@@ -1712,8 +1719,10 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.",
|
||||
EnterCommitFile: "Enter file / Toggle directory collapsed",
|
||||
EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.",
|
||||
EnterStaging: "Enter staging/patch building",
|
||||
ExitCustomPatchBuilder: `Exit custom patch builder`,
|
||||
ExitFocusedMainView: "Exit back to side panel",
|
||||
ToggleSelectionInFocusedMainView: "Show/hide selection",
|
||||
EnterUpstream: `Enter upstream as '<remote> <branchname>'`,
|
||||
InvalidUpstream: "Invalid upstream. Must be in the format '<remote> <branchname>'",
|
||||
NewRemote: `New remote`,
|
||||
@@ -1993,7 +2002,6 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
SortBasedOnReflog: "(based on reflog)",
|
||||
SortCommits: "Commit sort order",
|
||||
SortCommitsTooltip: "Change the sort order of the commits in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.sortOrder'.",
|
||||
CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
|
||||
CantChangeRenameThresholdError: "Cannot change the rename similarity threshold while in patch building mode, because the custom patch can't cope with a rename turning into a delete and add underneath it.",
|
||||
OpenCommitInBrowser: "Open commit in browser",
|
||||
ViewBisectOptions: "View bisect options",
|
||||
|
||||
@@ -367,6 +367,28 @@ func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// SelectionIsShown asserts that the view is showing an active selection highlight.
|
||||
func (self *ViewDriver) SelectionIsShown() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
view := self.getView()
|
||||
ok := view.Highlight && !view.HighlightInactive
|
||||
return ok, fmt.Sprintf("%s: expected an active selection to be shown, but it wasn't", self.context)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// SelectionIsHidden asserts that the view is showing no selection highlight (e.g. the
|
||||
// focused main view over "No changed files", where there's nothing to select).
|
||||
func (self *ViewDriver) SelectionIsHidden() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
ok := !self.getView().Highlight
|
||||
return ok, fmt.Sprintf("%s: expected no selection to be shown, but one was", self.context)
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// focus the view (assumes the view is a side-view)
|
||||
func (self *ViewDriver) Focus() *ViewDriver {
|
||||
viewName := self.getView().Name()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Build a custom patch by toggling a hunk into it from the focused main view of a commit's files, then apply it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
// Two separate change blocks, far enough apart to stay distinct hunks.
|
||||
shell.NewBranch("branch-b")
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
|
||||
shell.Commit("update")
|
||||
|
||||
shell.Checkout("branch-a")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("branch-a").IsSelected(),
|
||||
Contains("branch-b"),
|
||||
).
|
||||
Press(keys.Universal.NextItem).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("update").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// `a` extends the selection to the whole change block, then space toggles
|
||||
// just that block into the custom patch — the other block stays out.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// After toggling the block in, the selection advances to the next stageable
|
||||
// hunk (the NINE block) — like staging advances to the next hunk — across the
|
||||
// re-render (which, when the secondary view first appears, also re-wraps the
|
||||
// narrower diff).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
Contains("+NINE"),
|
||||
)
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
|
||||
// The secondary view updates live with the cumulative patch — just the toggled
|
||||
// block, not the other.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Content(DoesNotContain("NINE"))
|
||||
|
||||
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
|
||||
|
||||
// Only the toggled block reached the working tree: THREE is applied, NINE isn't.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
Content(Contains("THREE")).
|
||||
Content(DoesNotContain("NINE"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildFromMainViewWithUnsupportedPager = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Build a custom patch from a commit's focused main view under a pager that restructures the diff without emitting metadata; it falls back to the raw diff so the selection is still toggleable",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// `cat -n` numbers every line, which the buffer parser can't resolve, and cat
|
||||
// emits no metadata, so the focused main view must fall back to the raw diff.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: "cat -n"},
|
||||
}
|
||||
},
|
||||
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("update")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("update").IsSelected(),
|
||||
Contains("first commit"),
|
||||
)
|
||||
|
||||
// While browsing, the main view shows the pager's output (numbered lines).
|
||||
t.Views().Main().Content(Contains(" +THREE"))
|
||||
|
||||
// Focusing the main view falls back to the raw diff, so the change line is
|
||||
// resolved and the selection is toggleable into a custom patch.
|
||||
t.Views().Commits().Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Build a custom patch from the whole-commit diff in a commits panel's focused main view (without entering the commit files), then apply it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
// One commit touching two files, so the whole-commit diff is multi-file.
|
||||
shell.NewBranch("branch-b")
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
|
||||
shell.Commit("update")
|
||||
|
||||
shell.Checkout("branch-a")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("branch-a").IsSelected(),
|
||||
Contains("branch-b"),
|
||||
).
|
||||
Press(keys.Universal.NextItem).
|
||||
PressEnter()
|
||||
|
||||
// Focus the whole-commit diff straight from the sub-commits panel, rather than
|
||||
// entering the commit files panel first.
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("update").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
// The selection anchors on the first change line of the multi-file diff,
|
||||
// which belongs to file1.
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// `a` extends to the whole change block, then space toggles just file1's block
|
||||
// into the custom patch; file2's change, also in this diff, stays out.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// After toggling the block in, the selection advances to the next stageable
|
||||
// block — file2's BETA block — across the re-render (which, when the secondary
|
||||
// view first appears, re-wraps the narrower diff).
|
||||
SelectedLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
)
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
|
||||
// The secondary view shows the cumulative patch live — only file1's toggled
|
||||
// block, not file2's change from the same commit.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Content(DoesNotContain("BETA"))
|
||||
|
||||
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
|
||||
|
||||
// Only file1's toggled block reached the working tree; file2 is untouched.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
Content(Contains("THREE")).
|
||||
Content(DoesNotContain("BETA"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildMultiFileFromWholeCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Build a custom patch spanning two files from a commit's whole-commit diff in the focused main view, by toggling each file's hunk, then apply it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\ndelta\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.NewBranch("branch-b")
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\ndelta\n")
|
||||
shell.Commit("update")
|
||||
|
||||
shell.Checkout("branch-a")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Press(keys.Universal.NextItem).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("update").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
// Hunk mode is the default here, so the first change block of the multi-file
|
||||
// diff (file1's) is selected on focus.
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// Toggling the block in advances the selection to the next stageable block,
|
||||
// which crosses the file boundary into file2's BETA block; toggle it in too.
|
||||
SelectedLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
)
|
||||
|
||||
// The cumulative patch now spans both files.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
ContainsLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
)
|
||||
|
||||
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
|
||||
|
||||
// Both files' changes reached the working tree.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
ContainsLines(
|
||||
Contains("file1"),
|
||||
).
|
||||
ContainsLines(
|
||||
Contains("file2"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardLinesFromCommitMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discard a line from a local commit straight from the focused main view, without entering the commit files",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
|
||||
shell.CreateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n")
|
||||
shell.Commit("commit to remove from")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("commit to remove from").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
// Focus the commit's diff straight from the commits panel, rather than
|
||||
// entering the commit files panel first.
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+1st line"),
|
||||
).
|
||||
// Move down to the second line and discard just that one from the commit.
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("+2nd line"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard lines from commit")).
|
||||
Content(Equals("Are you sure you want to discard the selected lines from this commit?")).
|
||||
Confirm()
|
||||
|
||||
// After the rebase the commit keeps only the 1st and 3rd lines, and the selection
|
||||
// advances to the next surviving change (the 3rd line, now at the discarded line's
|
||||
// ordinal) rather than staying stale — like staging or discarding in the files panel.
|
||||
t.Views().Main().
|
||||
ContainsLines(
|
||||
Equals("+1st line"),
|
||||
Equals("+3rd line"),
|
||||
).
|
||||
Content(DoesNotContain("2nd line")).
|
||||
SelectedLines(
|
||||
Contains("+3rd line"),
|
||||
)
|
||||
},
|
||||
})
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var KeepSelectionAfterMovingPatchOutMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Moving a custom patch out of a commit from the focused main view re-establishes the (stale, multi-line) selection on a surviving change rather than leaving it painted over the shrunk diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("first commit")
|
||||
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("commit to move a patch out of")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("commit to move a patch out of").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
// Focus the commit's diff straight from the commits panel, rather than
|
||||
// entering the commit files panel first.
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
).
|
||||
// Toggle just the first line into a custom patch, then leave a multi-line
|
||||
// range selected — the patch move below doesn't go through the focused-main-
|
||||
// view action handlers, so without the preserve net this stale range would be
|
||||
// left painted over the shrunk diff. (Toggling advances the selection to the
|
||||
// next line, so go back to 'one' to anchor a range that spans the toggled line.)
|
||||
PressPrimaryAction().
|
||||
NavigateToLine(Contains("+one")).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+four")).
|
||||
SelectedLines(
|
||||
Contains("+one"),
|
||||
Contains("+two"),
|
||||
Contains("+three"),
|
||||
Contains("+four"),
|
||||
)
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Move patch out into index"))
|
||||
|
||||
// The moved line ('one') is gone from the commit, and the stale multi-line range
|
||||
// collapses to a single surviving change at the selection's top ordinal (now the
|
||||
// 'two' line) — just like discarding from the commit does.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+one")).
|
||||
ContainsLines(
|
||||
Equals("+two"),
|
||||
Equals("+three"),
|
||||
Equals("+four"),
|
||||
Equals("+five"),
|
||||
).
|
||||
SelectedLines(
|
||||
Contains("+two"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var RemoveLinesFromMainViewSecondary = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Remove a line from the custom patch by pressing space on it in the secondary (custom-patch) pane of the focused main view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
// Line mode, so we can include only some of a hunk's additions and exercise the
|
||||
// renumbering the aggregated patch applies to the included ones.
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
// Three consecutive additions in a single hunk.
|
||||
shell.NewBranch("branch-b")
|
||||
shell.UpdateFileAndAdd("file1", "one\nADDED1\nADDED2\nADDED3\ntwo\nthree\n")
|
||||
shell.Commit("update")
|
||||
|
||||
shell.Checkout("branch-a")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Press(keys.Universal.NextItem).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("update").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Include ADDED2 and ADDED3 in the patch but not ADDED1. Excluding ADDED1 shifts
|
||||
// the new-file line numbers of ADDED2/ADDED3 in the aggregated patch, which is what
|
||||
// used to make removing them by line number from the secondary act on the wrong line.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+ADDED1"),
|
||||
).
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("+ADDED2"),
|
||||
).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
SelectedLines(
|
||||
Contains("+ADDED2"),
|
||||
Contains("+ADDED3"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The cumulative patch holds ADDED2 and ADDED3 only.
|
||||
t.Views().Secondary().
|
||||
Content(Contains("+ADDED2")).
|
||||
Content(Contains("+ADDED3")).
|
||||
Content(DoesNotContain("ADDED1"))
|
||||
|
||||
// Tab into the secondary (custom-patch) pane; the selection lands on its first
|
||||
// change line, ADDED2.
|
||||
t.Views().Main().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+ADDED2"),
|
||||
).
|
||||
// Discarding from the commit makes no sense in the custom-patch preview, so it's
|
||||
// disabled here (you remove from the patch with space instead).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectToast(Contains("Cannot discard from the custom patch view"))
|
||||
|
||||
t.Views().Secondary().
|
||||
// Space removes the selected line from the patch — and removes ADDED2, not some
|
||||
// other line resolved from its shifted line number.
|
||||
PressPrimaryAction().
|
||||
// The selection lands on the next surviving change, ADDED3.
|
||||
SelectedLines(
|
||||
Contains("+ADDED3"),
|
||||
).
|
||||
Content(Contains("+ADDED3")).
|
||||
Content(DoesNotContain("ADDED2")).
|
||||
Content(DoesNotContain("ADDED1"))
|
||||
|
||||
// Applying confirms only ADDED3 was ever in the patch.
|
||||
t.Common().SelectPatchOption(MatchesRegexp(`Apply patch$`))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
Content(Contains("ADDED3")).
|
||||
Content(DoesNotContain("ADDED1")).
|
||||
Content(DoesNotContain("ADDED2"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ResetPatchBuiltFromMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Reset a custom patch that was built straight from the commits panel's main view, without ever having entered the commit files panel",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("branch-a")
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.NewBranch("branch-b")
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
shell.Commit("update")
|
||||
|
||||
shell.Checkout("branch-a")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Press(keys.Universal.NextItem).
|
||||
PressEnter()
|
||||
|
||||
// Build a patch straight from the whole-commit diff, never entering the commit
|
||||
// files panel — so its context is never set up with a ref for this commit.
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
|
||||
// Resetting the patch from the menu refreshes the (never-initialised) commit files
|
||||
// context; this used to crash on its nil ref.
|
||||
t.Views().Main().Press(keys.Universal.CreatePatchOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Patch options")).
|
||||
Select(Contains("Reset patch")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Information().Content(DoesNotContain("Building patch"))
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user