mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 05:26:53 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b516df91f6 | ||
|
|
ee3fc5d6dd | ||
|
|
2ea058d138 | ||
|
|
aa379bdefb | ||
|
|
8c3ce34e20 | ||
|
|
c5fadcd07e | ||
|
|
9f919a038b | ||
|
|
dc131a07cb | ||
|
|
4cb8883f3c |
@@ -709,6 +709,7 @@ keybinding:
|
||||
- "4"
|
||||
- "5"
|
||||
focusMainView: "0"
|
||||
jumpToFile: <ctrl+g>
|
||||
nextMatch: "n"
|
||||
prevMatch: "N"
|
||||
startSearch: /
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | Prev screen mode | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | Cancel | |
|
||||
| `` ? `` | Open keybindings menu | |
|
||||
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
@@ -234,6 +235,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Commit staged changes. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
@@ -316,6 +319,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Go to next hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Commit staged changes. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | 前の画面モード | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | キャンセル | |
|
||||
| `` ? `` | キーバインディングメニューを開く | |
|
||||
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
|
||||
@@ -203,6 +204,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` c `` | コミット | ステージされた変更をコミットします。 |
|
||||
| `` w `` | pre-commitフックなしで変更をコミット | |
|
||||
@@ -293,6 +296,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 次のハンクに移動 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` c `` | コミット | ステージされた変更をコミットします。 |
|
||||
| `` w `` | pre-commitフックなしで変更をコミット | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | 이전 스크린 모드 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | 취소 | |
|
||||
| `` ? `` | 매뉴 열기 | |
|
||||
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
@@ -95,6 +96,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
@@ -188,6 +191,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 다음 hunk를 선택 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
|
||||
| `` w `` | Commit changes without pre-commit hook | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | Vorige scherm modus | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | Annuleren | |
|
||||
| `` ? `` | Open menu | |
|
||||
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
@@ -242,6 +243,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
|
||||
| `` w `` | Commit veranderingen zonder pre-commit hook | |
|
||||
@@ -316,6 +319,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. |
|
||||
| `` w `` | Commit veranderingen zonder pre-commit hook | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | Poprzedni tryb ekranu | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | Anuluj | |
|
||||
| `` ? `` | Otwórz menu przypisań klawiszy | |
|
||||
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
|
||||
@@ -110,6 +111,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
|
||||
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
|
||||
@@ -211,6 +214,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Idź do następnego fragmentu | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Zatwierdź zmiany zatwierdzone. |
|
||||
| `` w `` | Zatwierdź zmiany bez hooka pre-commit | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | Modo de tela anterior | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | Cancelar | |
|
||||
| `` ? `` | Abrir o menu de atalhos do teclado | |
|
||||
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
@@ -246,6 +247,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Submeter mudanças em staging |
|
||||
| `` w `` | Fazer commit de alterações sem pré-commit | |
|
||||
@@ -325,6 +328,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Ir para o próximo trecho | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Commit | Submeter mudanças em staging |
|
||||
| `` w `` | Fazer commit de alterações sem pré-commit | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | Предыдущий режим экрана | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | Отменить | |
|
||||
| `` ? `` | Открыть меню | |
|
||||
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
@@ -85,6 +86,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Сохранить изменения | Commit staged changes. |
|
||||
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
|
||||
@@ -110,6 +113,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | Выбрать следующую часть | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` c `` | Сохранить изменения | Commit staged changes. |
|
||||
| `` w `` | Закоммитить изменения без предварительного хука коммита | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | 上一屏模式 | |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 打开菜单 | |
|
||||
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
|
||||
@@ -281,6 +282,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` c `` | 提交变更 | 提交暂存文件 |
|
||||
| `` w `` | 提交变更而无需预先提交钩子 | |
|
||||
@@ -322,6 +325,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 选择下一个区块 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` c `` | 提交变更 | 提交暂存文件 |
|
||||
| `` w `` | 提交变更而无需预先提交钩子 | |
|
||||
|
||||
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
|
||||
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
|
||||
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
@@ -70,6 +71,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
@@ -344,6 +347,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <right>, l `` | 選擇下一段 | |
|
||||
| `` N `` | Go to previous file | |
|
||||
| `` n `` | Go to next file | |
|
||||
| `` <ctrl+g> `` | Jump to file | |
|
||||
| `` G `` | Open pull request at selected line | Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found. |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` c `` | 提交變更 | 提交暫存區變更 |
|
||||
| `` w `` | 沒有預提交 hook 就提交更改 | |
|
||||
|
||||
@@ -516,6 +516,7 @@ type KeybindingUniversalConfig struct {
|
||||
PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"`
|
||||
JumpToBlock []Keybinding `yaml:"jumpToBlock"`
|
||||
FocusMainView Keybinding `yaml:"focusMainView"`
|
||||
JumpToFile Keybinding `yaml:"jumpToFile"`
|
||||
NextMatch Keybinding `yaml:"nextMatch"`
|
||||
PrevMatch Keybinding `yaml:"prevMatch"`
|
||||
StartSearch Keybinding `yaml:"startSearch"`
|
||||
@@ -1043,6 +1044,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
NextBlockAlt2: Keybinding{"<tab>"},
|
||||
JumpToBlock: []Keybinding{{"1"}, {"2"}, {"3"}, {"4"}, {"5"}},
|
||||
FocusMainView: Keybinding{"0"},
|
||||
JumpToFile: Keybinding{"<ctrl+g>"},
|
||||
NextMatch: Keybinding{"n"},
|
||||
PrevMatch: Keybinding{"N"},
|
||||
StartSearch: Keybinding{"/"},
|
||||
|
||||
@@ -19,16 +19,27 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// PullRequestDiff asks the panel this one was entered from. The files listed here are
|
||||
// those of the commits selected there, and that panel knows which branch's pull request
|
||||
// those commits are in.
|
||||
func (self *CommitFilesContext) PullRequestDiff() types.PullRequestDiff {
|
||||
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
|
||||
return parent.PullRequestDiff()
|
||||
}
|
||||
return types.PullRequestDiff{}
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
|
||||
@@ -32,16 +32,129 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// This panel shows the commits of the checked-out branch, and of the branches below it
|
||||
// in a stack. PullRequestDiff looks for their pull request among those branches.
|
||||
func (self *LocalCommitsContext) PullRequestDiff() types.PullRequestDiff {
|
||||
_, selectionStart, selectionEnd := self.GetSelectedItems()
|
||||
startIdx, endIdx := commitRangeShownInDiff(
|
||||
selectionStart, selectionEnd, self.GetSelectedLineIdx(), self.GetSelectedRefRangeForDiffFiles())
|
||||
model := self.ListContextTrait.c.Model()
|
||||
return pullRequestDiff(
|
||||
self.GetCommits(), startIdx, endIdx, model.CheckedOutBranch, model.Branches, model.PullRequestsMap)
|
||||
}
|
||||
|
||||
// commitRangeShownInDiff returns the indices of the newest and the oldest of the commits
|
||||
// whose combined diff a panel listing a branch's commits renders into the main view: the
|
||||
// selected range where it has a range to diff, and the commit at the cursor otherwise.
|
||||
// The panel hands the same selection to DiffHelper.GetUpdateTaskForRenderingCommitsDiff,
|
||||
// so anything acting on the diff on screen acts on the commits that diff is of.
|
||||
func commitRangeShownInDiff(
|
||||
selectionStart int, selectionEnd int, cursor int, refRange *types.RefRange,
|
||||
) (int, int) {
|
||||
if refRange != nil {
|
||||
return selectionStart, selectionEnd
|
||||
}
|
||||
return cursor, cursor
|
||||
}
|
||||
|
||||
// pullRequestDiff works out which branch's pull request would show the diff of the
|
||||
// commits from startIdx to endIdx of a panel listing the commits of listedBranch, and
|
||||
// which commit that diff starts after.
|
||||
//
|
||||
// That commit is the parent of the oldest of the commits. A pull request holds only the
|
||||
// commits of its branch that are pushed, so a parent that isn't pushed is none of its
|
||||
// own. A parent on the branch below in a stack isn't either, because the pull request
|
||||
// was opened against that branch. The diff then starts where the pull request itself
|
||||
// does, and an empty BaseHash says so.
|
||||
func pullRequestDiff(
|
||||
allCommits []*models.Commit,
|
||||
startIdx int,
|
||||
endIdx int,
|
||||
listedBranch string,
|
||||
branches []*models.Branch,
|
||||
pullRequests map[string]*models.GithubPullRequest,
|
||||
) types.PullRequestDiff {
|
||||
if listedBranch == "" || startIdx < 0 || endIdx >= len(allCommits) {
|
||||
return types.PullRequestDiff{}
|
||||
}
|
||||
|
||||
heads := pullRequestBranchHeads(branches, pullRequests)
|
||||
branchAt := func(idx int) string {
|
||||
return pullRequestBranchAt(allCommits, idx, heads, listedBranch)
|
||||
}
|
||||
|
||||
branch := branchAt(startIdx)
|
||||
diff := types.PullRequestDiff{
|
||||
Branch: branch,
|
||||
SpansBranches: branchAt(endIdx) != branch,
|
||||
Commits: allCommits[startIdx : endIdx+1],
|
||||
}
|
||||
|
||||
oldest := allCommits[endIdx]
|
||||
if oldest.IsFirstCommit() {
|
||||
return diff
|
||||
}
|
||||
parentHash := oldest.Parents()[0]
|
||||
_, parentIdx, found := lo.FindIndexOf(allCommits, func(commit *models.Commit) bool {
|
||||
return commit.Hash() == parentHash
|
||||
})
|
||||
if found && allCommits[parentIdx].Status == models.StatusPushed && branchAt(parentIdx) == branch {
|
||||
diff.BaseHash = parentHash
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
// pullRequestBranchHeads maps the head commit of each branch that has a pull request to
|
||||
// that branch. Where several share a head, the first of them in the list wins. The
|
||||
// checked-out branch comes first in the list, so it wins over the others.
|
||||
func pullRequestBranchHeads(
|
||||
branches []*models.Branch, pullRequests map[string]*models.GithubPullRequest,
|
||||
) map[string]string {
|
||||
heads := map[string]string{}
|
||||
for _, branch := range branches {
|
||||
if _, hasPullRequest := pullRequests[branch.Name]; !hasPullRequest || branch.CommitHash == "" {
|
||||
continue
|
||||
}
|
||||
if _, taken := heads[branch.CommitHash]; !taken {
|
||||
heads[branch.CommitHash] = branch.Name
|
||||
}
|
||||
}
|
||||
return heads
|
||||
}
|
||||
|
||||
// pullRequestBranchAt returns the branch whose pull request holds the commit at the given
|
||||
// index: the nearest branch with a pull request whose head is that commit or one listed
|
||||
// above it. A stack of branches lists the commits of each branch above those of the
|
||||
// branch it is based on, so this is the branch of the stack that the commit is on.
|
||||
// Commits that are in a main branch already are skipped, because a branch whose head is
|
||||
// one of them has been merged and doesn't belong to the stack. If no branch with a pull
|
||||
// request is found, it is the branch the panel lists, whether or not that one has a pull
|
||||
// request.
|
||||
func pullRequestBranchAt(
|
||||
commits []*models.Commit, idx int, pullRequestBranchHeads map[string]string, listedBranch string,
|
||||
) string {
|
||||
for i := idx; i >= 0; i-- {
|
||||
if commits[i].Status == models.StatusMerged {
|
||||
continue
|
||||
}
|
||||
if branch, ok := pullRequestBranchHeads[commits[i].Hash()]; ok {
|
||||
return branch
|
||||
}
|
||||
}
|
||||
return listedBranch
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -51,3 +55,184 @@ func TestAddMovingCommitsIndicator(t *testing.T) {
|
||||
},
|
||||
}, items)
|
||||
}
|
||||
|
||||
func TestCommitRangeShownInDiff(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
newer := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newer"})
|
||||
older := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "older"})
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
refRange *types.RefRange
|
||||
expectedStart int
|
||||
expectedEnd int
|
||||
}{
|
||||
{
|
||||
name: "a range is diffed as a whole",
|
||||
refRange: &types.RefRange{From: older, To: newer},
|
||||
expectedStart: 1,
|
||||
expectedEnd: 3,
|
||||
},
|
||||
{
|
||||
name: "without a range to diff, only the commit at the cursor is",
|
||||
expectedStart: 3,
|
||||
expectedEnd: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
startIdx, endIdx := commitRangeShownInDiff(1, 3, 3, s.refRange)
|
||||
assert.Equal(t, s.expectedStart, startIdx)
|
||||
assert.Equal(t, s.expectedEnd, endIdx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestDiff(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commit := func(hash string, parent string, status models.CommitStatus) *models.Commit {
|
||||
return models.NewCommit(hashPool, models.NewCommitOpts{
|
||||
Hash: hash,
|
||||
Parents: lo.Ternary(parent == "", []string{}, []string{parent}),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
// A stack of two branches, as the panel lists them: newest first. The checked-out
|
||||
// branch "upper" has had its tip amended since it was pushed; it is based on
|
||||
// "lower", which is based on a commit that is in a main branch already.
|
||||
upperAmended := commit("upper-amended", "upper-2", models.StatusUnpushed)
|
||||
upper2 := commit("upper-2", "upper-1", models.StatusPushed)
|
||||
upper1 := commit("upper-1", "lower-2", models.StatusPushed)
|
||||
lower2 := commit("lower-2", "lower-1", models.StatusPushed)
|
||||
lower1 := commit("lower-1", "merged", models.StatusPushed)
|
||||
merged := commit("merged", "ancient", models.StatusMerged)
|
||||
ancient := commit("ancient", "", models.StatusMerged)
|
||||
allCommits := []*models.Commit{upperAmended, upper2, upper1, lower2, lower1, merged, ancient}
|
||||
elsewhere := commit("elsewhere", "unlisted", models.StatusPushed)
|
||||
|
||||
branches := []*models.Branch{
|
||||
{Name: "upper", CommitHash: "upper-amended"},
|
||||
{Name: "lower", CommitHash: "lower-2"},
|
||||
// A branch without a pull request in the middle of "upper", and one whose
|
||||
// pull request was merged.
|
||||
{Name: "no-pull-request", CommitHash: "upper-1"},
|
||||
{Name: "merged-feature", CommitHash: "merged"},
|
||||
}
|
||||
pullRequests := map[string]*models.GithubPullRequest{
|
||||
"upper": {Number: 2},
|
||||
"lower": {Number: 1},
|
||||
"merged-feature": {Number: 0},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
commits []*models.Commit
|
||||
startIdx int
|
||||
endIdx int
|
||||
listsNoBranch bool
|
||||
withoutPullRequests bool
|
||||
expected types.PullRequestDiff
|
||||
}{
|
||||
{
|
||||
name: "a commit of the checked-out branch starts after its parent",
|
||||
startIdx: 1,
|
||||
endIdx: 1,
|
||||
expected: types.PullRequestDiff{Branch: "upper", Commits: []*models.Commit{upper2}, BaseHash: "upper-1"},
|
||||
},
|
||||
{
|
||||
name: "a range starts after the parent of its oldest commit",
|
||||
startIdx: 0,
|
||||
endIdx: 1,
|
||||
expected: types.PullRequestDiff{
|
||||
Branch: "upper", Commits: []*models.Commit{upperAmended, upper2}, BaseHash: "upper-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a commit of the branch below is in that branch's pull request",
|
||||
startIdx: 3,
|
||||
endIdx: 3,
|
||||
expected: types.PullRequestDiff{Branch: "lower", Commits: []*models.Commit{lower2}, BaseHash: "lower-1"},
|
||||
},
|
||||
{
|
||||
name: "the first commit of a branch starts where its pull request does",
|
||||
startIdx: 4,
|
||||
endIdx: 4,
|
||||
expected: types.PullRequestDiff{Branch: "lower", Commits: []*models.Commit{lower1}},
|
||||
},
|
||||
{
|
||||
name: "and so does the first commit of a branch based on another",
|
||||
startIdx: 2,
|
||||
endIdx: 2,
|
||||
expected: types.PullRequestDiff{Branch: "upper", Commits: []*models.Commit{upper1}},
|
||||
},
|
||||
{
|
||||
name: "so does a range reaching down to it",
|
||||
startIdx: 1,
|
||||
endIdx: 2,
|
||||
expected: types.PullRequestDiff{Branch: "upper", Commits: []*models.Commit{upper2, upper1}},
|
||||
},
|
||||
{
|
||||
name: "a range reaching down into the branch below spans both",
|
||||
startIdx: 2,
|
||||
endIdx: 3,
|
||||
expected: types.PullRequestDiff{
|
||||
Branch: "upper", SpansBranches: true, Commits: []*models.Commit{upper1, lower2},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a range down from the head of the branch below is that branch's",
|
||||
startIdx: 3,
|
||||
endIdx: 4,
|
||||
expected: types.PullRequestDiff{Branch: "lower", Commits: []*models.Commit{lower2, lower1}},
|
||||
},
|
||||
{
|
||||
name: "the head of a branch that is in a main branch already doesn't count",
|
||||
startIdx: 5,
|
||||
endIdx: 5,
|
||||
expected: types.PullRequestDiff{Branch: "lower", Commits: []*models.Commit{merged}},
|
||||
},
|
||||
{
|
||||
name: "the first commit of the repository starts where the pull request does",
|
||||
startIdx: 6,
|
||||
endIdx: 6,
|
||||
expected: types.PullRequestDiff{Branch: "lower", Commits: []*models.Commit{ancient}},
|
||||
},
|
||||
{
|
||||
name: "a parent the panel doesn't list is none of the pull request's",
|
||||
commits: []*models.Commit{elsewhere},
|
||||
startIdx: 0,
|
||||
endIdx: 0,
|
||||
expected: types.PullRequestDiff{Branch: "upper", Commits: []*models.Commit{elsewhere}},
|
||||
},
|
||||
{
|
||||
name: "without any pull request, a commit is the listed branch's",
|
||||
startIdx: 3,
|
||||
endIdx: 3,
|
||||
withoutPullRequests: true,
|
||||
expected: types.PullRequestDiff{Branch: "upper", Commits: []*models.Commit{lower2}, BaseHash: "lower-1"},
|
||||
},
|
||||
{
|
||||
name: "a panel listing no local branch's commits has no pull request",
|
||||
startIdx: 1,
|
||||
endIdx: 1,
|
||||
listsNoBranch: true,
|
||||
},
|
||||
{
|
||||
name: "nothing is shown",
|
||||
commits: []*models.Commit{},
|
||||
startIdx: -1,
|
||||
endIdx: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
commits := lo.Ternary(s.commits == nil, allCommits, s.commits)
|
||||
listedBranch := lo.Ternary(s.listsNoBranch, "", "upper")
|
||||
prs := lo.Ternary(s.withoutPullRequests, nil, pullRequests)
|
||||
assert.Equal(t, s.expected, pullRequestDiff(commits, s.startIdx, s.endIdx, listedBranch, branches, prs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,34 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// This panel shows the commits of the branch it was entered from, and of the branches
|
||||
// below it in a stack. PullRequestDiff looks for their pull request among those
|
||||
// branches. The panel is also entered from a tag, a remote branch and the reflog, none of
|
||||
// which a pull request is made from.
|
||||
func (self *SubCommitsContext) PullRequestDiff() types.PullRequestDiff {
|
||||
branch, ok := self.GetRef().(*models.Branch)
|
||||
if !ok {
|
||||
return types.PullRequestDiff{}
|
||||
}
|
||||
|
||||
_, selectionStart, selectionEnd := self.GetSelectedItems()
|
||||
startIdx, endIdx := commitRangeShownInDiff(
|
||||
selectionStart, selectionEnd, self.GetSelectedLineIdx(), self.GetSelectedRefRangeForDiffFiles())
|
||||
return pullRequestDiff(
|
||||
self.GetCommits(), startIdx, endIdx, branch.Name, self.c.Model().Branches, self.c.Model().PullRequestsMap)
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -208,7 +208,7 @@ func (self *BranchesController) GetOnRenderToMain() func() {
|
||||
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
task = rendererTask
|
||||
|
||||
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
|
||||
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
|
||||
rendererTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
|
||||
rendererTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
|
||||
@@ -462,7 +462,7 @@ func (self *BranchesController) handleCreatePullRequestMenu(selectedBranch *mode
|
||||
|
||||
func (self *BranchesController) getPullRequestURL() (string, error) {
|
||||
branch := self.context().GetSelected()
|
||||
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
|
||||
if pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name); ok {
|
||||
return pr.Url, nil
|
||||
}
|
||||
|
||||
@@ -855,15 +855,11 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
|
||||
}
|
||||
|
||||
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
|
||||
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
||||
}
|
||||
|
||||
return nil
|
||||
return self.c.Helpers().Host.NoPullRequestDisabledReason(branch.Name)
|
||||
}
|
||||
|
||||
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
|
||||
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
|
||||
if !ok {
|
||||
// Should be guarded against by the DisabledReason check, but be defensive in case
|
||||
// PullRequestsMap was updated concurrently by a background refresh
|
||||
|
||||
@@ -2,8 +2,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
@@ -443,11 +441,7 @@ func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string,
|
||||
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
|
||||
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
|
||||
func (self *CommitDiffActions) patchBuilderPath(path string) string {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relativePath)
|
||||
return repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
}
|
||||
|
||||
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
|
||||
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
@@ -130,3 +131,14 @@ func isInDir(path string, dir string) bool {
|
||||
// "." is the root item, which contains every file
|
||||
return dir == "." || strings.HasPrefix(path, dir+"/")
|
||||
}
|
||||
|
||||
// repoRelativePath turns the absolute path a diff line carries into the one git speaks
|
||||
// of the file: relative to the worktree, with forward slashes. It is "" for a path that
|
||||
// is no file of this repo, which a diff renderer's own naming of a line can produce.
|
||||
func repoRelativePath(worktreePath string, absolutePath string) string {
|
||||
relativePath, err := filepath.Rel(worktreePath, absolutePath)
|
||||
if err != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -74,6 +75,14 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
|
||||
Description: self.c.Tr.CycleDiffRenderersReverse,
|
||||
Tooltip: self.c.Tr.CycleDiffRenderersReverseTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.JumpToFile),
|
||||
Handler: opts.Guards.NoPopupPanel(self.jumpToFileInDiff),
|
||||
Description: self.c.Tr.JumpToFileInDiff,
|
||||
DescriptionFunc: self.jumpToFileInDiffDescription,
|
||||
Tooltip: self.c.Tr.JumpToFileInDiffTooltip,
|
||||
OpensMenu: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
||||
Handler: self.escape,
|
||||
@@ -206,6 +215,58 @@ func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
// jumpToFileInDiff offers the files of the diff the main section is showing in a menu,
|
||||
// and scrolls that pane to the file picked. The panel the user is in keeps the focus;
|
||||
// they are reading the diff from there, and the next commit or file to read is picked
|
||||
// there too.
|
||||
func (self *GlobalController) jumpToFileInDiff() error {
|
||||
pane := self.diffPane()
|
||||
if pane == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(pane, self.c.Tr.JumpToFileInDiff)
|
||||
}
|
||||
|
||||
// jumpToFileInDiffDescription qualifies the command's description so that it is listed
|
||||
// only where it applies. A command with no description is left out of the keybindings
|
||||
// menu.
|
||||
//
|
||||
// It doesn't apply where the main section is showing content that is no diff of the
|
||||
// panel's — a branch's commit log, the status dashboard, a message. Nor does it while
|
||||
// the focus is in one of the panes, which bind the key themselves; the menu would
|
||||
// otherwise offer it twice there, once for the pane and once among the global keys.
|
||||
//
|
||||
// The static Description stays as it is: the cheatsheets are generated from that, and
|
||||
// they document what a key does rather than when it applies.
|
||||
func (self *GlobalController) jumpToFileInDiffDescription() string {
|
||||
_, focusIsInAPane := self.c.Context().Current().(*context.MainContext)
|
||||
if focusIsInAPane || self.diffPane() == nil {
|
||||
return ""
|
||||
}
|
||||
return self.c.Tr.JumpToFileInDiff
|
||||
}
|
||||
|
||||
// diffPane returns the pane of the main section showing the diff of the panel the user
|
||||
// is in, and nil when neither of them is showing one. A pane is cleared as it is
|
||||
// emptied, so a pane that says it is showing a diff is showing one. Its window also has
|
||||
// to be showing the pane. Resolving a conflicted file puts the merge conflicts view
|
||||
// there instead, and the pane behind it goes on holding the diff it last rendered.
|
||||
//
|
||||
// Where both panes show a diff — the unstaged and staged sides of a file — the answer
|
||||
// is the upper one, the pane the keys for scrolling the section act on.
|
||||
func (self *GlobalController) diffPane() *context.MainContext {
|
||||
for _, pane := range []*context.MainContext{
|
||||
self.c.Contexts().Normal, self.c.Contexts().NormalSecondary,
|
||||
} {
|
||||
onScreen := self.c.Helpers().Window.GetContextForWindow(pane.GetWindowName()) == pane
|
||||
if onScreen && pane.ContentIsDiff() {
|
||||
return pane
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *GlobalController) createOptionsMenu() error {
|
||||
return (&OptionsMenuAction{c: self.c}).Call()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// OpenJumpToFileMenu offers the files of the diff the given pane is showing in a menu,
|
||||
// so that one of them can be gone to directly rather than by stepping through the diff
|
||||
// a file at a time. Picking one goes to where that file's diff begins, the way stepping
|
||||
// to it with next-file does: the selection moves there in a focused pane, and an
|
||||
// unfocused one scrolls the file to the top.
|
||||
//
|
||||
// The diff is read to the end before the menu is built: a file below the part of it that
|
||||
// has been read so far is in neither the list nor the view, and reaching the far end of
|
||||
// a long diff is what the menu is for.
|
||||
func (self *DiffLineHelper) OpenJumpToFileMenu(pane types.DiffPaneContext, title string) error {
|
||||
manager := self.c.GetViewBufferManagerForView(pane.GetView())
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error { return self.showJumpToFileMenu(pane, title) })
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// showJumpToFileMenu offers the diff's files by the paths git names them by. Each item
|
||||
// names its file rather than the row that file begins at, so that a diff re-rendered
|
||||
// while the menu is up is jumped into at the row the file begins at now.
|
||||
//
|
||||
// A menu offering the one file of a single-file diff would be a menu with nothing to
|
||||
// choose, so it says what it found instead. It says it here rather than as the key's
|
||||
// disabled reason because how many files there are is only known once the diff has been
|
||||
// read to the end, which is too much to do for every keypress that asks whether a key
|
||||
// applies.
|
||||
func (self *DiffLineHelper) showJumpToFileMenu(pane types.DiffPaneContext, title string) error {
|
||||
view := pane.GetView()
|
||||
files := self.FilesInDiff(view)
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(files) == 1 {
|
||||
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + self.c.Tr.OnlyOneFileInDiff)
|
||||
return nil
|
||||
}
|
||||
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
menuItems := lo.Map(files, func(path string, _ int) *types.MenuItem {
|
||||
label := repoRelativePath(worktreePath, path)
|
||||
if label == "" {
|
||||
label = path
|
||||
}
|
||||
return &types.MenuItem{
|
||||
Label: label,
|
||||
OnPress: func() error {
|
||||
if target, ok := self.StartOfFileInDiff(view, path); ok {
|
||||
self.PlaceNavigationTarget(pane, target, true)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: title,
|
||||
Items: menuItems,
|
||||
FilterAsYouType: true,
|
||||
})
|
||||
}
|
||||
@@ -154,11 +154,19 @@ func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
|
||||
func repoRelativePaths(worktreePath string, paths []string) []string {
|
||||
relPaths := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
relPath, err := filepath.Rel(worktreePath, path)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
continue
|
||||
if relPath := repoRelativePath(worktreePath, path); relPath != "" {
|
||||
relPaths = append(relPaths, relPath)
|
||||
}
|
||||
relPaths = append(relPaths, filepath.ToSlash(relPath))
|
||||
}
|
||||
return relPaths
|
||||
}
|
||||
|
||||
// repoRelativePath turns one such path into the repo-relative one, and is "" for a path
|
||||
// that lies outside the worktree.
|
||||
func repoRelativePath(worktreePath string, path string) string {
|
||||
relPath, err := filepath.Rel(worktreePath, path)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relPath)
|
||||
}
|
||||
|
||||
@@ -78,16 +78,26 @@ func (self *DiffLineHelper) ChangeLineOrdinals(
|
||||
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
|
||||
// prefixes and so needs nothing.
|
||||
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
|
||||
toRepoTerms := self.repoTermsMapper(view)
|
||||
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
|
||||
info.Path = toRepoTerms(info.Path)
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
// repoTermsMapper returns how a path recovered from view is brought into the repo's
|
||||
// terms, for the callers that have a path rather than a whole identity to bring. The
|
||||
// mapping is worked out once, per view rather than per path.
|
||||
func (self *DiffLineHelper) repoTermsMapper(view *gocui.View) func(string) string {
|
||||
if !self.ShowsCustomPatch(view) {
|
||||
return infos
|
||||
return func(path string) string { return path }
|
||||
}
|
||||
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
|
||||
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
|
||||
return info
|
||||
})
|
||||
return func(path string) string {
|
||||
return repoPathOfTreePath(path, treesDir, worktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
|
||||
@@ -335,62 +345,98 @@ func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, f
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
|
||||
// empty for a row whose identity couldn't be recovered.
|
||||
// FilesInDiff lists the files of view's (possibly multi-file) rendered diff, in the
|
||||
// order it shows them, by the paths of the repo's files. It is what a menu offering to
|
||||
// jump between them is built from; jumping to one of them goes to StartOfFileInDiff.
|
||||
func (self *DiffLineHelper) FilesInDiff(view *gocui.View) []string {
|
||||
return lo.Map(fileStarts(self.filePaths(view)),
|
||||
func(start diffFileStart, _ int) string { return start.path })
|
||||
}
|
||||
|
||||
// StartOfFileInDiff returns the view line the given file's section of view's rendered
|
||||
// diff begins at. That is the row file navigation lands on, so jumping to a file from a
|
||||
// menu and stepping to it with next-file land in the same place. ok is false for a file
|
||||
// the diff doesn't show, e.g. because it was re-rendered since the file was listed.
|
||||
func (self *DiffLineHelper) StartOfFileInDiff(view *gocui.View, path string) (int, bool) {
|
||||
start, ok := lo.Find(fileStarts(self.filePaths(view)), func(start diffFileStart) bool {
|
||||
return start.path == path
|
||||
})
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return view.ViewLineForBufferLine(start.row)
|
||||
}
|
||||
|
||||
// filePaths resolves view's rendered diff to the path each buffer line belongs to, in
|
||||
// the repo's terms, and empty for a row whose identity couldn't be recovered. Naming the
|
||||
// files the way the rest of the queries name them means a row of the custom patch's
|
||||
// preview belongs to the repo's file rather than to the copy of it in the tree the patch
|
||||
// was materialized into, so that both halves of a change belong to the same file however
|
||||
// the diff renderer states them.
|
||||
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
toRepoTerms := self.repoTermsMapper(view)
|
||||
paths := make([]string, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok {
|
||||
paths[i] = row.info.Path
|
||||
paths[i] = toRepoTerms(row.info.Path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), the first located row of the file adjacent to `from`
|
||||
// in the given direction — the row file navigation lands on. It is the pure index
|
||||
// arithmetic behind AdjacentFile.
|
||||
// diffFileStart is where one file of a diff begins: the path of the file, and the row
|
||||
// of the diff its section starts at.
|
||||
type diffFileStart struct {
|
||||
path string
|
||||
row int
|
||||
}
|
||||
|
||||
// fileStarts finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), where each file of it begins, in the order the diff
|
||||
// shows them.
|
||||
//
|
||||
// A file is identified by its path, so we look for where the path changes, skipping
|
||||
// rows that carry none: those are the blank separator rows between files, or the
|
||||
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
||||
// So the landing row is the file's header wherever the source says so — a parseable
|
||||
// buffer, or a renderer that tags its headers — and the file's first content line
|
||||
// otherwise, which is an accepted degradation.
|
||||
// A file is identified by its path, and the rows showing it are consecutive, so a path
|
||||
// differing from the one before it begins a file. Rows carrying no path are passed over:
|
||||
// those are the blank separator rows between files, or the header rows of a diff
|
||||
// renderer that doesn't state which file its headers belong to. So a file begins at its
|
||||
// header wherever the source says so (a parseable buffer, or a renderer that tags its
|
||||
// headers), and at its first content line otherwise, which is an accepted degradation.
|
||||
func fileStarts(paths []string) []diffFileStart {
|
||||
starts := []diffFileStart{}
|
||||
previousPath := ""
|
||||
for row, path := range paths {
|
||||
if path == "" || path == previousPath {
|
||||
continue
|
||||
}
|
||||
previousPath = path
|
||||
starts = append(starts, diffFileStart{path: path, row: row})
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// fileStart returns where the file adjacent to `from` in the given direction begins —
|
||||
// the row file navigation lands on. It is the pure index arithmetic behind AdjacentFile.
|
||||
// ok is false at the first or last file of the diff.
|
||||
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
|
||||
}
|
||||
}
|
||||
starts := fileStarts(paths)
|
||||
_, anchor, ok := lo.FindIndexOf(starts, func(start diffFileStart) bool {
|
||||
return start.path == anchorPath
|
||||
})
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Walk back past the current file (its rows and any unlocated ones) to the previous
|
||||
// file's last located row, then back over that whole file, landing on its first.
|
||||
i := from
|
||||
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
target := anchor + lo.Ternary(forward, 1, -1)
|
||||
if target < 0 || target >= len(starts) {
|
||||
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
|
||||
return starts[target].row, true
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
|
||||
@@ -48,6 +48,41 @@ func TestChangeBlockStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStarts(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
paths []string
|
||||
expected []diffFileStart
|
||||
}{
|
||||
{
|
||||
name: "a parseable diff begins each file at its header",
|
||||
paths: []string{"a", "a", "a", "b", "b"},
|
||||
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
|
||||
},
|
||||
{
|
||||
name: "a diff whose headers carry no path begins each file at its first content line",
|
||||
paths: []string{"", "", "a", "a", "", "", "b", "b"},
|
||||
expected: []diffFileStart{{path: "a", row: 2}, {path: "b", row: 6}},
|
||||
},
|
||||
{
|
||||
name: "an unlocated row within a file doesn't begin another one",
|
||||
paths: []string{"a", "", "a", "b"},
|
||||
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
|
||||
},
|
||||
{
|
||||
name: "a diff with no located rows shows no files",
|
||||
paths: []string{"", ""},
|
||||
expected: []diffFileStart{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, fileStarts(s.paths))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStart(t *testing.T) {
|
||||
// A parseable two-file diff: every row carries its file's path, headers included,
|
||||
// as the buffer parser reports it.
|
||||
|
||||
@@ -33,6 +33,21 @@ func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
|
||||
return git_commands.DiffModeRendered
|
||||
}
|
||||
|
||||
// DiffRowsCanBePlaced reports whether the rows of the diff the main view is about to be
|
||||
// given can be placed in the file they show. git's own diff describes itself, whether it
|
||||
// is what the user configured or what MainViewDiffMode is about to substitute for a
|
||||
// rendering that can't be acted on; any other rendering says where its rows belong only
|
||||
// if it states records for them.
|
||||
//
|
||||
// It is what anything that means to go from a row back to the file it shows has to ask
|
||||
// first: with neither records nor a diff that describes itself, there is nothing to go
|
||||
// on, and offering the user the way there would be offering nothing.
|
||||
func (self *DiffLineHelper) DiffRowsCanBePlaced() bool {
|
||||
return !self.diffNeedsMetadata() ||
|
||||
self.MainViewDiffMode() == git_commands.DiffModeRaw ||
|
||||
self.diffRendererEmitsMetadata()
|
||||
}
|
||||
|
||||
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
|
||||
// diff again — which, the main view now holding focus, is git's own diff rather than
|
||||
// the renderer's — and calls place once that is on screen.
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Putting a selection in the focused main view: where it starts out, and how it is
|
||||
// widened to a whole change block. Both are answered from what the view is showing,
|
||||
// as recovered by the queries next door.
|
||||
// Putting a selection in the focused main view: where it starts out, where a jump
|
||||
// leaves it, and how it is widened to a whole change block. All three are answered
|
||||
// from what the view is showing, as recovered by the queries next door.
|
||||
|
||||
// EstablishSelection turns on the focused main view's selection once the view has
|
||||
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
|
||||
@@ -63,6 +63,66 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext,
|
||||
self.ShowSelectionAtLine(view, target, false)
|
||||
}
|
||||
|
||||
// PlaceNavigationTarget moves the pane's selection to the row a jump found, bringing
|
||||
// it on screen if it isn't already. With no selection to move — a pane that isn't
|
||||
// focused, or one showing a diff with nothing selectable in it — the row goes to the
|
||||
// top of the view instead, that being all a jump can do there.
|
||||
//
|
||||
// alignTop asks for the target to become the view's top line, so that everything that
|
||||
// begins there is on screen. It only applies to a target the view has to scroll to: a
|
||||
// jump to something already on screen leaves the view alone, there being nothing to
|
||||
// gain from moving what the user is looking at. In hunk mode what ends up selected is
|
||||
// the first change block at or below the target, which a large context size can put
|
||||
// further down than a screenful; the selection is then scrolled into view as any other
|
||||
// jump's is, and the alignment gives way to that.
|
||||
func (self *DiffLineHelper) PlaceNavigationTarget(
|
||||
pane types.DiffPaneContext, target int, alignTop bool,
|
||||
) {
|
||||
view := pane.GetView()
|
||||
if !view.Highlight {
|
||||
view.SetOrigin(0, target)
|
||||
return
|
||||
}
|
||||
if alignTop {
|
||||
self.scrollTargetToTop(pane, target)
|
||||
}
|
||||
// Jumping to another block or file moves the cursor without shift held, so a
|
||||
// range that grows only while shift is held collapses rather than stretching all
|
||||
// the way to the target. A sticky range stretches instead; this is the point of
|
||||
// being sticky.
|
||||
self.CollapseNonStickyRange(pane)
|
||||
if pane.DiffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
self.SelectChangeBlock(pane, target, true)
|
||||
return
|
||||
}
|
||||
// Line mode leaves a single-line selection at the target; an active range extends
|
||||
// to it, the anchor being untouched.
|
||||
self.ShowSelectionAtLine(view, target, true)
|
||||
}
|
||||
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
// the view where it is when that row is on screen already. The last screenful of the
|
||||
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
|
||||
// showing.
|
||||
func (self *DiffLineHelper) scrollTargetToTop(pane types.DiffPaneContext, target int) {
|
||||
view := pane.GetView()
|
||||
originY, height := pane.GetViewTrait().ViewPortYBounds()
|
||||
if target >= originY && target < originY+height {
|
||||
return
|
||||
}
|
||||
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
|
||||
}
|
||||
|
||||
// CollapseNonStickyRange drops a range that only grows while shift is held back to a
|
||||
// single line at the cursor.
|
||||
func (self *DiffLineHelper) CollapseNonStickyRange(pane types.DiffPaneContext) {
|
||||
sel := pane.DiffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
pane.GetView().CancelRangeSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// changeToSelectOnScreen returns the change line keyboard focus establishes the
|
||||
// selection on. In hunk mode that is the first block that begins on screen, so that
|
||||
// the block being offered up is one the user can see the extent of, falling back to a
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// A diff opens with a diffstat naming every file in it, above the diff of each of
|
||||
// them. Here each of those names is made a link to where that file's diff begins, so
|
||||
// that a file of a long diff can be gone to by clicking the line that names it.
|
||||
//
|
||||
// The names are recognized in the output as it is written to the pane, where they cost
|
||||
// next to nothing to find. The diffstat is git's own text whichever renderer the diff
|
||||
// goes through — delta, diff-so-fancy and difftastic all pass it on untouched — and it
|
||||
// comes first, so the scan for it ends with it.
|
||||
|
||||
// DiffStatLinkScheme names a link to a file of the diff the pane is showing, as
|
||||
// lazygit-edit names one that opens a file in the editor. The link is never handed to
|
||||
// the terminal — gocui takes the escape sequence out of the content and gives the URL
|
||||
// back when the cell it covers is clicked — so the path in it needs no escaping.
|
||||
const DiffStatLinkScheme = "lazygit-diff-file://"
|
||||
|
||||
// diffStatEntryPattern matches a line of a diffstat and captures the path it states.
|
||||
// Such a line holds the name of the file, padded out to the width of the longest, then
|
||||
// the number of lines it changes (or "Bin" for a binary file) and the graph of them.
|
||||
//
|
||||
// The name is captured greedily, so that the separator found is the last one on the
|
||||
// line rather than one in a file name that contains " | " itself.
|
||||
var diffStatEntryPattern = regexp.MustCompile(`^ (.*[^ ]) +\| +(?:Bin|\d+)`)
|
||||
|
||||
// DiffStatLinkWriter hands a pane's content on to it, turning the file names in the
|
||||
// diffstat the content opens with into links (see DiffStatLinkScheme).
|
||||
type DiffStatLinkWriter struct {
|
||||
writer io.Writer
|
||||
|
||||
// Whether the diffstat is still to come, is being written now, or is behind us. It
|
||||
// is behind us once a line comes that is no entry of it, or that the diff proper
|
||||
// begins with, and nothing past that is looked at. A name down there names the file
|
||||
// the reader is already in.
|
||||
//
|
||||
// It is atomic because the render is begun on the UI thread while the content of
|
||||
// it arrives on the goroutine reading the command's output.
|
||||
state atomic.Int32
|
||||
}
|
||||
|
||||
type diffStatState int32
|
||||
|
||||
const (
|
||||
diffStatToCome diffStatState = iota
|
||||
inDiffStat
|
||||
diffStatDone
|
||||
)
|
||||
|
||||
func NewDiffStatLinkWriter(writer io.Writer) *DiffStatLinkWriter {
|
||||
return &DiffStatLinkWriter{writer: writer}
|
||||
}
|
||||
|
||||
// BeginRender starts a fresh render, whose own diffstat is the one to look for. It is
|
||||
// called as the render is asked for, before any of it is written.
|
||||
//
|
||||
// linkFiles says whether this render is one whose file names lead anywhere: the pane's
|
||||
// own diff, whose rows can be placed in the files they show. Either is passed through
|
||||
// untouched without it. Content that is no diff of the panel's — a commit log, a
|
||||
// message — has no diffstat in it, and a line of one that happens to read like an entry
|
||||
// of a diffstat names no file to go to. A rendering whose rows nothing can place does
|
||||
// have the files in it, but nothing to find the one a name stands for with.
|
||||
func (self *DiffStatLinkWriter) BeginRender(linkFiles bool) {
|
||||
self.setState(lo.Ternary(linkFiles, diffStatToCome, diffStatDone))
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) getState() diffStatState {
|
||||
return diffStatState(self.state.Load())
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) setState(state diffStatState) {
|
||||
self.state.Store(int32(state))
|
||||
}
|
||||
|
||||
func (self *DiffStatLinkWriter) Write(p []byte) (int, error) {
|
||||
linked := self.withFileNameLinked(p)
|
||||
|
||||
written, err := self.writer.Write(linked)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if written < len(linked) {
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
// The caller is owed an answer about what it gave us, not about what we passed on.
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// withFileNameLinked returns the given line of the render with the name in it linked,
|
||||
// where the line is an entry of the diffstat.
|
||||
func (self *DiffStatLinkWriter) withFileNameLinked(line []byte) []byte {
|
||||
state := self.getState()
|
||||
if state == diffStatDone {
|
||||
return line
|
||||
}
|
||||
|
||||
if beginsTheDiffItself(line) {
|
||||
// A diffstat that hasn't come by now isn't coming: the pane is showing a diff
|
||||
// that was asked for without one.
|
||||
self.setState(diffStatDone)
|
||||
return line
|
||||
}
|
||||
|
||||
match := diffStatEntry(line)
|
||||
if match == nil {
|
||||
if state == inDiffStat {
|
||||
self.setState(diffStatDone)
|
||||
}
|
||||
return line
|
||||
}
|
||||
self.setState(inDiffStat)
|
||||
|
||||
start, end := match[2], match[3]
|
||||
// The link states the name as it reads on screen, so that a renderer that colors
|
||||
// the diffstat doesn't put escape sequences into it.
|
||||
name := utils.Decolorise(string(line[start:end]))
|
||||
linked := make([]byte, 0, len(line)+len(name)+32)
|
||||
linked = append(linked, line[:start]...)
|
||||
linked = append(linked, style.PrintHyperlink(string(line[start:end]), DiffStatLinkScheme+name)...)
|
||||
return append(linked, line[end:]...)
|
||||
}
|
||||
|
||||
// diffLineRecordOpener opens an OSC 1717 record, ahead of the version whose fields the
|
||||
// record states (see parseDiffLineMetadata).
|
||||
const diffLineRecordOpener = "\x1b]1717;"
|
||||
|
||||
// beginsTheDiffItself reports whether the line is one of the diff proper rather than
|
||||
// one of the diffstat above it: git's own header for a file, or a line a renderer
|
||||
// states a record about (see statesADiffLine).
|
||||
func beginsTheDiffItself(line []byte) bool {
|
||||
return bytes.HasPrefix(line, []byte("diff --")) || statesADiffLine(line)
|
||||
}
|
||||
|
||||
// diffLineRecordKinds are the kinds of record a renderer states about a line of the
|
||||
// diff itself. A line stating one of them is below the whole diffstat, which is what
|
||||
// makes them the end of the search for it.
|
||||
//
|
||||
// They are listed here rather than read off the parser's table (see
|
||||
// diffLineTypeFromMetadata), which answers a different question: whether a record can be
|
||||
// read at all. A kind the protocol gains for something above the diff — one renderer
|
||||
// stated the commit line — would belong in that table and not in this list, and taking
|
||||
// the table for this would end the search where the diffstat hasn't even begun. Every
|
||||
// kind of the protocol is held against this list by a test.
|
||||
var diffLineRecordKinds = []string{"c", "a", "d", "f", "h"}
|
||||
|
||||
// statesADiffLine reports whether the line carries a record in which a diff renderer
|
||||
// states which line of which file it is rendering. Those records are about the lines of
|
||||
// the diff, and the diffstat comes before all of them.
|
||||
//
|
||||
// The kind the record states has to be one of diffLineRecordKinds. A record of any
|
||||
// other kind says nothing about where the diffstat ends, so the search goes on past it.
|
||||
//
|
||||
// The version the record opens with is passed over rather than read. This asks which
|
||||
// lines a renderer states records for, and the answer holds whichever version of the
|
||||
// protocol it speaks. A record with nothing after the version is the handshake a
|
||||
// renderer announces itself with, which is about no line, so the search goes on past it.
|
||||
func statesADiffLine(line []byte) bool {
|
||||
for rest := line; ; {
|
||||
at := bytes.Index(rest, []byte(diffLineRecordOpener))
|
||||
if at == -1 {
|
||||
return false
|
||||
}
|
||||
rest = rest[at+len(diffLineRecordOpener):]
|
||||
|
||||
// The record reads <version>;<kind>;, and the kind is a single character.
|
||||
digits := 0
|
||||
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
|
||||
digits++
|
||||
}
|
||||
kind := digits + 1
|
||||
if digits == 0 || kind+1 >= len(rest) || rest[digits] != ';' || rest[kind+1] != ';' {
|
||||
continue
|
||||
}
|
||||
if lo.Contains(diffLineRecordKinds, string(rest[kind:kind+1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// diffStatEntry matches line against diffStatEntryPattern, behind the two checks that
|
||||
// answer for nearly every line of a diff without the pattern being run at all: an entry
|
||||
// of a diffstat is indented by a space, and holds the separator. The indices it returns
|
||||
// are into the whole line, whatever the match was made past.
|
||||
func diffStatEntry(line []byte) []int {
|
||||
start := handshakeEnd(line)
|
||||
rest := line[start:]
|
||||
if len(rest) == 0 || rest[0] != ' ' || bytes.IndexByte(rest, '|') == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
match := diffStatEntryPattern.FindSubmatchIndex(rest)
|
||||
for i := range match {
|
||||
if match[i] >= 0 {
|
||||
match[i] += start
|
||||
}
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// handshakeEnd returns where the record a renderer announces itself with ends, for a
|
||||
// line that opens with one, and 0 for every other line.
|
||||
//
|
||||
// A renderer writes the handshake before anything else and with no newline after it,
|
||||
// so it lands at the start of the first line of its output. For a diff with nothing
|
||||
// above its diffstat — the diff of a range of commits, or of a stash — that is the line
|
||||
// naming the first file in it, and the entry begins after the record rather than at the
|
||||
// start of the line.
|
||||
func handshakeEnd(line []byte) int {
|
||||
if !bytes.HasPrefix(line, []byte(diffLineRecordOpener)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
after := len(diffLineRecordOpener)
|
||||
for after < len(line) && line[after] >= '0' && line[after] <= '9' {
|
||||
after++
|
||||
}
|
||||
|
||||
// Either terminator ends a record. One that goes on into a field instead is about
|
||||
// a line of the diff, which is below the whole diffstat and no entry of it.
|
||||
switch {
|
||||
case after < len(line) && line[after] == '\x07':
|
||||
return after + 1
|
||||
case after+1 < len(line) && line[after] == '\x1b' && line[after+1] == '\\':
|
||||
return after + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// JumpToFileNamedInDiffStat goes to the file of the pane's diff that the given diffstat
|
||||
// entry names, for a click on the link made for that entry. It lands the way picking
|
||||
// the file from the menu of the diff's files does.
|
||||
//
|
||||
// The diff is read to the end first, as it is for that menu. The diffstat is on screen
|
||||
// only while the view is at the top of the diff, so the file clicked is nearly always
|
||||
// below the part of it that has been read.
|
||||
func (self *DiffLineHelper) JumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
|
||||
manager := self.c.GetViewBufferManagerForView(pane.GetView())
|
||||
if manager == nil {
|
||||
return
|
||||
}
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.jumpToFileNamedInDiffStat(pane, entry)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *DiffLineHelper) jumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
|
||||
view := pane.GetView()
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
files := self.FilesInDiff(view)
|
||||
names := lo.Map(files, func(file string, _ int) string {
|
||||
return repoRelativePath(worktreePath, file)
|
||||
})
|
||||
|
||||
index, ok := fileNamedByDiffStatEntry(entry, names)
|
||||
if !ok {
|
||||
self.c.ErrorToast(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.NoFileInDiffNamed, map[string]string{"path": entry}))
|
||||
return
|
||||
}
|
||||
|
||||
if target, ok := self.StartOfFileInDiff(view, files[index]); ok {
|
||||
self.PlaceNavigationTarget(pane, target, true)
|
||||
}
|
||||
}
|
||||
|
||||
// fileNamedByDiffStatEntry returns which of the diff's files a diffstat entry names.
|
||||
//
|
||||
// An entry states the path as the diffstat has room for it rather than as git names
|
||||
// the file. A path too long for the column is cut off on the left behind "...", and a
|
||||
// rename is compacted to the "{old => new}" form. So the name is looked for among the
|
||||
// files the diff turned out to hold, whole and then as the end of one, and is taken
|
||||
// only where it names a single file.
|
||||
func fileNamedByDiffStatEntry(entry string, paths []string) (int, bool) {
|
||||
name := renamedTo(strings.TrimSpace(entry))
|
||||
|
||||
if index, ok := theOneMatching(paths, func(p string) bool { return p == name }); ok {
|
||||
return index, true
|
||||
}
|
||||
|
||||
// Where the diffstat cut the path off, what is left is the end of it. The cut is at
|
||||
// a directory boundary where there is room for one, and inside the file name where
|
||||
// there isn't.
|
||||
tail := strings.TrimPrefix(name, "...")
|
||||
return theOneMatching(paths, func(p string) bool { return strings.HasSuffix(p, tail) })
|
||||
}
|
||||
|
||||
// renamedTo returns the path a diffstat entry for a rename leaves the file at, and the
|
||||
// entry itself for any other one. A rename states both paths, with whatever they have
|
||||
// in common written once: "dir/{old => new}/file", or "old => new" where they share
|
||||
// nothing. The part shared with the old path is gone along with the "{" when the entry
|
||||
// is cut off on the left, which leaves a path to match the end of.
|
||||
func renamedTo(entry string) string {
|
||||
const arrow = " => "
|
||||
at := strings.Index(entry, arrow)
|
||||
if at == -1 {
|
||||
return entry
|
||||
}
|
||||
|
||||
shared := ""
|
||||
if brace := strings.Index(entry[:at], "{"); brace != -1 {
|
||||
shared = entry[:brace]
|
||||
}
|
||||
renamed := entry[at+len(arrow):]
|
||||
if closing := strings.Index(renamed, "}"); closing != -1 {
|
||||
return shared + renamed[:closing] + renamed[closing+1:]
|
||||
}
|
||||
return shared + renamed
|
||||
}
|
||||
|
||||
// theOneMatching returns the index of the one element the predicate holds for, and
|
||||
// false where it holds for none of them or for several.
|
||||
func theOneMatching(paths []string, matches func(string) bool) (int, bool) {
|
||||
found := -1
|
||||
for i, candidate := range paths {
|
||||
if !matches(candidate) {
|
||||
continue
|
||||
}
|
||||
if found != -1 {
|
||||
return 0, false
|
||||
}
|
||||
found = i
|
||||
}
|
||||
return found, found != -1
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// link is the line the writer is expected to produce for a diffstat entry: the space
|
||||
// it is indented by, the name linked, and the rest of the line as it came.
|
||||
func link(name string, rest string) string {
|
||||
return " " + style.PrintHyperlink(name, DiffStatLinkScheme+name) + rest
|
||||
}
|
||||
|
||||
// record is the OSC 1717 record a diff renderer speaking the given version of the
|
||||
// protocol states a line of the given kind with, as it precedes that line in its
|
||||
// output.
|
||||
func record(version string, kind string) string {
|
||||
return fmt.Sprintf("%s%s;%s;;;pkg/gui.go\x1b\\", diffLineRecordOpener, version, kind)
|
||||
}
|
||||
|
||||
// handshake is the record a renderer announces the protocol with: the version it
|
||||
// speaks, and nothing about any line. Renderers end their records with either
|
||||
// terminator, so both turn up.
|
||||
var (
|
||||
handshake = diffLineRecordOpener + "1\x1b\\"
|
||||
handshakeBel = diffLineRecordOpener + "1\x07"
|
||||
)
|
||||
|
||||
func TestDiffStatLinkWriter(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
linkFiles bool
|
||||
lines []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "links the entries of the diffstat, and nothing after it",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
"commit 1234567",
|
||||
"",
|
||||
" A commit message",
|
||||
"",
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
" dir/other.go | 3 ++-",
|
||||
" 2 files changed, 8 insertions(+), 7 deletions(-)",
|
||||
"",
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
expected: []string{
|
||||
"commit 1234567",
|
||||
"",
|
||||
" A commit message",
|
||||
"",
|
||||
link("pkg/gui.go", " | 12 ++++++------"),
|
||||
link("dir/other.go", " | 3 ++-"),
|
||||
" 2 files changed, 8 insertions(+), 7 deletions(-)",
|
||||
"",
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "links a binary file, a file that changes nothing, and a name with spaces",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
" logo.png | Bin 0 -> 1234 bytes",
|
||||
" script.sh | 0",
|
||||
" my file.txt | 2 +-",
|
||||
},
|
||||
expected: []string{
|
||||
link("logo.png", " | Bin 0 -> 1234 bytes"),
|
||||
link("script.sh", " | 0"),
|
||||
link("my file.txt", " | 2 +-"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops looking once the diff itself has begun",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
expected: []string{
|
||||
"diff --git a/pkg/gui.go b/pkg/gui.go",
|
||||
" a context line that reads like an entry | 3 ++-",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops looking at the first line of the diff a renderer states",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
" pkg/gui.go | 1 +",
|
||||
record("1", "c") + " a context line of the diff",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
expected: []string{
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
record("1", "c") + " a context line of the diff",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
},
|
||||
{
|
||||
// A renderer is free to state records about something that is no line of
|
||||
// the diff, and one has stated the commit line above it. The diffstat is
|
||||
// below such a record as much as it is below the handshake.
|
||||
name: "keeps looking past a record of a kind it doesn't know",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
record("1", "C") + "commit 1234567",
|
||||
" pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
record("1", "C") + "commit 1234567",
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stops for a record of a protocol version it doesn't read",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
record("7", "f") + "── pkg/gui.go ──",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
expected: []string{
|
||||
record("7", "f") + "── pkg/gui.go ──",
|
||||
" this/looks/like/a/diff/stat | 2 +",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps looking past the handshake, which states no line",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshake,
|
||||
" pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
handshake,
|
||||
link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A diff with nothing above its diffstat. The handshake is written with no
|
||||
// newline after it, so it runs into the entry naming the first file.
|
||||
name: "links an entry the handshake runs into",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshake + " pkg/gui.go | 1 +",
|
||||
" dir/other.go | 2 +-",
|
||||
},
|
||||
expected: []string{
|
||||
handshake + link("pkg/gui.go", " | 1 +"),
|
||||
link("dir/other.go", " | 2 +-"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "links an entry a handshake ended with a BEL runs into",
|
||||
linkFiles: true,
|
||||
lines: []string{
|
||||
handshakeBel + " pkg/gui.go | 1 +",
|
||||
},
|
||||
expected: []string{
|
||||
handshakeBel + link("pkg/gui.go", " | 1 +"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leaves a render whose file names lead nowhere alone",
|
||||
linkFiles: false,
|
||||
lines: []string{
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
},
|
||||
expected: []string{
|
||||
" pkg/gui.go | 12 ++++++------",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
buffer := &bytes.Buffer{}
|
||||
writer := NewDiffStatLinkWriter(buffer)
|
||||
writer.BeginRender(scenario.linkFiles)
|
||||
|
||||
for _, line := range scenario.lines {
|
||||
written, err := writer.Write([]byte(line + "\n"))
|
||||
assert.NoError(t, err)
|
||||
// The writer answers for what it was given, not for what it passed on.
|
||||
assert.Equal(t, len(line)+1, written)
|
||||
}
|
||||
|
||||
assert.Equal(t, strings.Join(scenario.expected, "\n")+"\n", buffer.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffStatLinkWriterStartsLookingAgainWithEachRender(t *testing.T) {
|
||||
buffer := &bytes.Buffer{}
|
||||
writer := NewDiffStatLinkWriter(buffer)
|
||||
|
||||
for range 2 {
|
||||
buffer.Reset()
|
||||
writer.BeginRender(true)
|
||||
_, _ = writer.Write([]byte(" pkg/gui.go | 1 +\n"))
|
||||
_, _ = writer.Write([]byte(" 1 file changed, 1 insertion(+)\n"))
|
||||
|
||||
assert.Equal(t, link("pkg/gui.go", " | 1 +")+
|
||||
"\n 1 file changed, 1 insertion(+)\n", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNamedByDiffStatEntry(t *testing.T) {
|
||||
paths := []string{
|
||||
"pkg/gui.go",
|
||||
"pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
"vendor/github.com/gdamore/tcell/v3/AUTHORS",
|
||||
"pkg/gocui/AUTHORS",
|
||||
"renamed.txt",
|
||||
"a/very/deeply/nested/directory/structure/some_long_file_name.txt",
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
entry string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "a path the diffstat had room for",
|
||||
entry: "pkg/gui.go",
|
||||
expected: "pkg/gui.go",
|
||||
},
|
||||
{
|
||||
name: "a path cut off at a directory boundary",
|
||||
entry: ".../tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
expected: "pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
|
||||
},
|
||||
{
|
||||
name: "a path cut off inside the file name",
|
||||
entry: "..._long_file_name.txt",
|
||||
expected: "a/very/deeply/nested/directory/structure/some_long_file_name.txt",
|
||||
},
|
||||
{
|
||||
name: "a rename, stated as the part the two paths share",
|
||||
entry: "vendor/github.com/gdamore/tcell/{v2 => v3}/AUTHORS",
|
||||
expected: "vendor/github.com/gdamore/tcell/v3/AUTHORS",
|
||||
},
|
||||
{
|
||||
name: "a rename whose shared part was cut off along with the brace",
|
||||
entry: ".../github.com/jesseduffield => pkg}/gocui/AUTHORS",
|
||||
expected: "pkg/gocui/AUTHORS",
|
||||
},
|
||||
{
|
||||
name: "a rename of paths that share nothing",
|
||||
entry: "original.txt => renamed.txt",
|
||||
expected: "renamed.txt",
|
||||
},
|
||||
{
|
||||
name: "a name of no file of the diff",
|
||||
entry: "pkg/nowhere.go",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "a name several files of the diff end with",
|
||||
entry: "AUTHORS",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
index, ok := fileNamedByDiffStatEntry(scenario.entry, paths)
|
||||
if scenario.expected == "" {
|
||||
assert.False(t, ok)
|
||||
return
|
||||
}
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, scenario.expected, paths[index])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryRecordKindIsWeighedAgainstTheDiffStat fails when the protocol gains a kind
|
||||
// of record that nobody has placed relative to the diffstat. Where a line stating that
|
||||
// kind can only come below the diffstat, it ends the search for it and belongs in
|
||||
// diffLineRecordKinds; where it can come above the diff — a record about the commit,
|
||||
// say — it says nothing about where the diffstat ends, and belongs in the list here.
|
||||
//
|
||||
// The parser takes the kind as a field rather than a table, so the kinds it reads are
|
||||
// found by asking it about each character in turn.
|
||||
func TestEveryRecordKindIsWeighedAgainstTheDiffStat(t *testing.T) {
|
||||
kindsAboveTheDiff := []string{}
|
||||
|
||||
printable := lo.RangeFrom(byte(' '), 0x7f-' ')
|
||||
kindsTheParserReads := lo.FilterMap(printable, func(char byte, _ int) (string, bool) {
|
||||
kind := string([]byte{char})
|
||||
_, ok := diffLineTypeFromMetadata(kind)
|
||||
return kind, ok
|
||||
})
|
||||
|
||||
assert.ElementsMatch(t,
|
||||
append(slices.Clone(diffLineRecordKinds), kindsAboveTheDiff...),
|
||||
kindsTheParserReads,
|
||||
"a kind of record has been added to the protocol without being weighed "+
|
||||
"against the diffstat; see this test's comment for where it belongs")
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// this helper just wraps our hosting_service package
|
||||
// this helper wraps our hosting_service package, and answers what we know about the
|
||||
// repo's pull requests on it
|
||||
|
||||
type HostHelper struct {
|
||||
c *HelperCommon
|
||||
@@ -18,6 +21,24 @@ func NewHostHelper(
|
||||
}
|
||||
}
|
||||
|
||||
// PullRequestForBranch returns the pull request the given local branch is the head of,
|
||||
// and false where it has none. That covers knowing of no pull requests at all: the repo
|
||||
// may not be on GitHub, or the answer may not have arrived yet.
|
||||
func (self *HostHelper) PullRequestForBranch(branchName string) (*models.GithubPullRequest, bool) {
|
||||
pr, ok := self.c.Model().PullRequestsMap[branchName]
|
||||
return pr, ok
|
||||
}
|
||||
|
||||
// NoPullRequestDisabledReason disables a command that acts on a branch's pull request
|
||||
// while that branch has none.
|
||||
func (self *HostHelper) NoPullRequestDisabledReason(branchName string) *types.DisabledReason {
|
||||
if _, ok := self.PullRequestForBranch(branchName); !ok {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *HostHelper) GetPullRequestURL(from string, to string) (string, error) {
|
||||
mgr, err := self.getHostingServiceMgr()
|
||||
if err != nil {
|
||||
|
||||
@@ -667,15 +667,11 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
|
||||
branch := self.c.Model().CheckedOutBranch
|
||||
if _, ok := self.c.Model().PullRequestsMap[branch]; !ok {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
||||
}
|
||||
return nil
|
||||
return self.c.Helpers().Host.NoPullRequestDisabledReason(self.c.Model().CheckedOutBranch)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) openPRInBrowser() error {
|
||||
pr, ok := self.c.Model().PullRequestsMap[self.c.Model().CheckedOutBranch]
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.c.Model().CheckedOutBranch)
|
||||
if !ok {
|
||||
// Should be guarded against by the DisabledReason check, but be defensive in case
|
||||
// PullRequestsMap was updated concurrently by a background refresh
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
@@ -154,6 +159,23 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.JumpToFile),
|
||||
Handler: self.openJumpToFileMenu,
|
||||
Description: self.c.Tr.JumpToFile,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.JumpToFile),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
OpensMenu: true,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser),
|
||||
Handler: self.openPullRequestAtSelectedLine,
|
||||
Description: self.c.Tr.OpenPullRequestAtSelectedLine,
|
||||
DescriptionFunc: self.pullRequestDescription(self.c.Tr.OpenPullRequestAtSelectedLine),
|
||||
GetDisabledReason: self.openPullRequestDisabledReason,
|
||||
Tooltip: self.c.Tr.OpenPullRequestAtSelectedLineTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
||||
Handler: self.escape,
|
||||
@@ -319,17 +341,22 @@ func (self *MainViewController) isDiffView() bool {
|
||||
return self.diffMainViewType() != types.DiffMainViewTypeNone
|
||||
}
|
||||
|
||||
// sidePanelBeneath returns the side panel this pane is showing the content of, and nil
|
||||
// when there is none. The IsInStack guard is essential: NextInStack panics for a context
|
||||
// that isn't in the stack, and GetKeybindings (which leads here) also runs for off-stack
|
||||
// panes — at startup and while generating the cheatsheets, where the stack is empty.
|
||||
func (self *MainViewController) sidePanelBeneath() types.Context {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return nil
|
||||
}
|
||||
return self.c.Context().NextInStack(self.context)
|
||||
}
|
||||
|
||||
// diffMainViewType reports what the diff in the focused main view belongs to, taken
|
||||
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
|
||||
// stack or has no diff panel beneath it. The IsInStack guard is essential:
|
||||
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
|
||||
// leads here) also runs for off-stack panes — at startup and while generating the
|
||||
// cheatsheets, where the stack is empty.
|
||||
// stack or has no diff panel beneath it.
|
||||
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
|
||||
if diffContext, ok := self.sidePanelBeneath().(types.DiffMainViewContext); ok {
|
||||
return diffContext.GetDiffMainViewType()
|
||||
}
|
||||
return types.DiffMainViewTypeNone
|
||||
@@ -339,10 +366,7 @@ func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
// hand out the diff it rendered there. nil when this pane isn't on the stack, or the
|
||||
// panel beneath shows no diff.
|
||||
func (self *MainViewController) diffSource() types.FocusedMainViewDiffSource {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return nil
|
||||
}
|
||||
sidePanel := self.c.Context().NextInStack(self.context)
|
||||
sidePanel := self.sidePanelBeneath()
|
||||
if sidePanel == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -438,6 +462,19 @@ func (self *MainViewController) diffActionDescription(staging string, patchBuild
|
||||
}
|
||||
}
|
||||
|
||||
// pullRequestDescription describes a command that acts on the pull request of the
|
||||
// branch the diff belongs to. Over a diff that belongs to no branch (the working tree's,
|
||||
// a stash entry's) it describes it as nothing; this keeps the command out of the
|
||||
// keybindings menu there.
|
||||
func (self *MainViewController) pullRequestDescription(description string) func() string {
|
||||
return self.diffSelectionDescription(func() string {
|
||||
if self.pullRequestDiff().Branch == "" {
|
||||
return ""
|
||||
}
|
||||
return description
|
||||
})
|
||||
}
|
||||
|
||||
// copySelection copies the selected diff lines to the clipboard — not as the diff
|
||||
// renderer drew them, but as they read in the diff itself, which is both what you meant
|
||||
// to copy and the only form a renderer can't have mangled. A selection that is all
|
||||
@@ -532,6 +569,50 @@ func (self *MainViewController) discardSelectionDisabledReason() *types.Disabled
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPullRequestDisabledReason disables opening a line in the pull request where the
|
||||
// pull request has no view of what is on screen. The branch may have no pull request,
|
||||
// the commits may be on several branches of a stack and so in several pull requests,
|
||||
// and the pane may be showing a diff that is not the commit's own: a diff against
|
||||
// another ref, or the custom patch, whose lines sit at the numbers the patch gives them
|
||||
// rather than the commit's.
|
||||
func (self *MainViewController) openPullRequestDisabledReason() *types.DisabledReason {
|
||||
if reason := self.diffSelectionDisabledReason(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
if self.c.Modes().Diffing.Active() {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NotAvailableInDiffingMode}
|
||||
}
|
||||
if self.c.Helpers().DiffLine.ShowsCustomPatch(self.context.GetView()) {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NotAvailableForCustomPatch}
|
||||
}
|
||||
diff := self.pullRequestDiff()
|
||||
if diff.SpansBranches {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CommitsInSeveralPullRequests}
|
||||
}
|
||||
if reason := self.c.Helpers().Host.NoPullRequestDisabledReason(diff.Branch); reason != nil {
|
||||
return reason
|
||||
}
|
||||
return self.commitsOutsidePullRequestDisabledReason(diff.Commits)
|
||||
}
|
||||
|
||||
// commitsOutsidePullRequestDisabledReason disables opening a line of a diff whose
|
||||
// commits the pull request doesn't hold: it holds the commits of its branch that are on
|
||||
// the remote, so an unpushed commit is none of its own, and neither is one that is in a
|
||||
// main branch already and so from before the branch. Asked for such a commit, its pages
|
||||
// say they can't find it.
|
||||
func (self *MainViewController) commitsOutsidePullRequestDisabledReason(commits []*models.Commit) *types.DisabledReason {
|
||||
if lo.EveryBy(commits, func(commit *models.Commit) bool {
|
||||
return commit.Status == models.StatusPushed
|
||||
}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(commits) == 1 {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CommitNotInPullRequest}
|
||||
}
|
||||
return &types.DisabledReason{Text: self.c.Tr.CommitsNotInPullRequest}
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
@@ -770,50 +851,12 @@ func (self *MainViewController) prevFile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
|
||||
// screen if it isn't already.
|
||||
//
|
||||
// alignTop asks for the target to become the view's top line, so that everything that
|
||||
// begins there is on screen. It only applies to a target the view has to scroll to: a
|
||||
// jump to something already on screen leaves the view alone, there being nothing to
|
||||
// gain from moving what the user is looking at. In hunk mode what ends up selected is
|
||||
// the first change block at or below the target, which a large context size can put
|
||||
// further down than a screenful; the selection is then scrolled into view as any other
|
||||
// jump's is, and the alignment gives way to that.
|
||||
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
v.SetOrigin(0, target)
|
||||
return
|
||||
}
|
||||
if alignTop {
|
||||
self.scrollTargetToTop(target)
|
||||
}
|
||||
// Jumping to another block or file moves the cursor without shift held, so a
|
||||
// range that grows only while shift is held collapses rather than stretching all
|
||||
// the way to the target. A sticky range stretches instead; this is the point of
|
||||
// being sticky.
|
||||
self.collapseNonStickyRange()
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
|
||||
self.selectHunkAround(target, true)
|
||||
return
|
||||
}
|
||||
// Line mode leaves a single-line selection at the target; an active range extends
|
||||
// to it, the anchor being untouched.
|
||||
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
|
||||
self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop)
|
||||
}
|
||||
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
// the view where it is when that row is on screen already. The last screenful of the
|
||||
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
|
||||
// showing.
|
||||
func (self *MainViewController) scrollTargetToTop(target int) {
|
||||
view := self.context.GetView()
|
||||
originY, height := self.context.GetViewTrait().ViewPortYBounds()
|
||||
if target >= originY && target < originY+height {
|
||||
return
|
||||
}
|
||||
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
|
||||
func (self *MainViewController) openJumpToFileMenu() error {
|
||||
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(self.context, self.c.Tr.JumpToFile)
|
||||
}
|
||||
|
||||
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
|
||||
@@ -845,17 +888,7 @@ func (self *MainViewController) collapseForLineMove() {
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
return
|
||||
}
|
||||
self.collapseNonStickyRange()
|
||||
}
|
||||
|
||||
// collapseNonStickyRange drops a range that only grows while shift is held back to a
|
||||
// single line at the cursor.
|
||||
func (self *MainViewController) collapseNonStickyRange() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
}
|
||||
self.c.Helpers().DiffLine.CollapseNonStickyRange(self.context)
|
||||
}
|
||||
|
||||
// adjustSelection moves the selection by delta view lines, for the plain up/down and
|
||||
@@ -1049,6 +1082,95 @@ func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) er
|
||||
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
|
||||
}
|
||||
|
||||
// openPullRequestAtSelectedLine opens the pull request of the branch whose commit the
|
||||
// main view is showing the diff of, at the line the selection is on, so that the line
|
||||
// can be commented on there.
|
||||
func (self *MainViewController) openPullRequestAtSelectedLine() error {
|
||||
diff := self.pullRequestDiff()
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(diff.Branch)
|
||||
if !ok {
|
||||
// Guarded against by the disabled reason, but a refresh in the background may
|
||||
// have taken the pull request away since it was asked.
|
||||
return errors.New(self.c.Tr.NoPullRequestForBranch)
|
||||
}
|
||||
|
||||
if len(diff.Commits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
view := self.context.GetView()
|
||||
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if relativePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
||||
url := githubPullRequestLineURL(pr.Url, githubCommitRange(diff.Commits, diff.BaseHash), relativePath, info)
|
||||
return self.c.OS().OpenLink(url)
|
||||
}
|
||||
|
||||
// pullRequestDiff returns the commits whose diff this pane is showing, and the branch
|
||||
// whose pull request would show that diff, as the panel beneath names them. The diff's
|
||||
// line numbers are the ones the pull request's page for those commits shows. Where no
|
||||
// pull request shows the diff, the branch is "".
|
||||
func (self *MainViewController) pullRequestDiff() types.PullRequestDiff {
|
||||
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
|
||||
if !ok {
|
||||
return types.PullRequestDiff{}
|
||||
}
|
||||
return prContext.PullRequestDiff()
|
||||
}
|
||||
|
||||
// githubPullRequestLineURL builds the URL of a line of a file, in the diff a pull request
|
||||
// shows for the given commits. The file is named by the SHA-256 of its path as git spells
|
||||
// it, and the line by which side of the diff it is on.
|
||||
//
|
||||
// GitHub documents none of this; the form was read off the URLs its own pages carry (see
|
||||
// https://github.com/orgs/community/discussions/55764).
|
||||
func githubPullRequestLineURL(
|
||||
prURL string, commitRange string, relativePath string, info types.DiffLineInfo,
|
||||
) string {
|
||||
pathHash := sha256.Sum256([]byte(relativePath))
|
||||
anchor := "diff-" + hex.EncodeToString(pathHash[:]) + githubDiffLineSuffix(info)
|
||||
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitRange, anchor)
|
||||
}
|
||||
|
||||
// githubCommitRange names the commits a pull request is to show the diff of: a single
|
||||
// commit by its hash, and a range of them as the commit the diff starts after, then the
|
||||
// commit it ends at. A range that starts where the pull request itself does names BASE
|
||||
// as the commit it starts after, the keyword its pages use for the commit the pull
|
||||
// request was opened against; naming that commit by its hash gets a page that says it
|
||||
// can't find those commits.
|
||||
func githubCommitRange(commits []*models.Commit, baseHash string) string {
|
||||
newest := commits[0].Hash()
|
||||
if len(commits) == 1 {
|
||||
return newest
|
||||
}
|
||||
if baseHash == "" {
|
||||
baseHash = "BASE"
|
||||
}
|
||||
return baseHash + ".." + newest
|
||||
}
|
||||
|
||||
// githubDiffLineSuffix names a line within a file's diff: R for the new version of the
|
||||
// file, L for the old one, which is where a deleted line is found. Some rows are no line
|
||||
// of the file at all (the header naming it, or a marker like "\ No newline at end of
|
||||
// file"); those name none, and the anchor points at the file itself.
|
||||
func githubDiffLineSuffix(info types.DiffLineInfo) string {
|
||||
switch info.Type {
|
||||
case types.DiffLineDeleted:
|
||||
return fmt.Sprintf("L%d", info.OldLine)
|
||||
case types.DiffLineAdded, types.DiffLineContext, types.DiffLineHunkHeader:
|
||||
return fmt.Sprintf("R%d", info.NewLine)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MainViewController) openSearch() error {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadToEnd(func() {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGithubPullRequestLineURL(t *testing.T) {
|
||||
const prURL = "https://github.com/jesseduffield/lazygit/pull/1234"
|
||||
const commitHash = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
|
||||
|
||||
// The anchor names the file by the SHA-256 of its repo-relative path, taken over
|
||||
// exactly those bytes: no leading slash, no trailing newline, forward slashes.
|
||||
const fileHash = "067980d6efc4249367ceb61b0d93a00bca100a0ddb6d4a72b6dbb0eb9d3825cc" // "dir/file1"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
path string
|
||||
info types.DiffLineInfo
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "an added line is on the right side of the diff",
|
||||
path: "dir/file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineAdded, NewLine: 12},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R12",
|
||||
},
|
||||
{
|
||||
name: "a deleted line is on the left side, at the line it sat on",
|
||||
path: "dir/file1",
|
||||
// A deletion's NewLine is only where it sits in the new version of the
|
||||
// file; the line it is, is the old one.
|
||||
info: types.DiffLineInfo{Type: types.DiffLineDeleted, NewLine: 12, OldLine: 34},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "L34",
|
||||
},
|
||||
{
|
||||
name: "a context line is on the right side too",
|
||||
path: "dir/file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineContext, NewLine: 7, OldLine: 5},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R7",
|
||||
},
|
||||
{
|
||||
name: "a hunk header points at the first line of its hunk",
|
||||
path: "dir/file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineHunkHeader, NewLine: 20},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash + "R20",
|
||||
},
|
||||
{
|
||||
name: "the header naming a file points at the file alone",
|
||||
path: "dir/file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineFileHeader},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash,
|
||||
},
|
||||
{
|
||||
name: "a row that is no line of the file points at the file alone",
|
||||
path: "dir/file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineOther},
|
||||
expected: prURL + "/changes/" + commitHash + "#diff-" + fileHash,
|
||||
},
|
||||
{
|
||||
name: "a file at the root of the repo",
|
||||
path: "file1",
|
||||
info: types.DiffLineInfo{Type: types.DiffLineAdded, NewLine: 1},
|
||||
expected: prURL + "/changes/" + commitHash +
|
||||
"#diff-c147efcfc2d7ea666a9e4f5187b115c90903f0fc896a56df9a6ef5d8f3fc9f31R1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, githubPullRequestLineURL(prURL, commitHash, s.path, s.info))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubCommitRange(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
newest := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newest"})
|
||||
oldest := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "oldest"})
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
commits []*models.Commit
|
||||
baseHash string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "a single commit is named on its own",
|
||||
commits: []*models.Commit{newest},
|
||||
baseHash: "parent",
|
||||
expected: "newest",
|
||||
},
|
||||
{
|
||||
name: "a range is named as the commits it lies between",
|
||||
commits: []*models.Commit{newest, oldest},
|
||||
baseHash: "parent",
|
||||
expected: "parent..newest",
|
||||
},
|
||||
{
|
||||
name: "a range starting where the pull request does lies above BASE",
|
||||
commits: []*models.Commit{newest, oldest},
|
||||
expected: "BASE..newest",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, githubCommitRange(s.commits, s.baseHash))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
@@ -262,11 +261,11 @@ func (self *WorkingTreeDiffActions) applyDiffLineSelection(
|
||||
// fileForDiffLinePath maps the absolute path a diff line carries to the working tree
|
||||
// file it belongs to, or nil for a path that is no file of this repo's working tree.
|
||||
func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.File {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil {
|
||||
relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if relativePath == "" {
|
||||
return nil
|
||||
}
|
||||
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
|
||||
return self.context().FileTreeViewModel.GetFile(relativePath)
|
||||
}
|
||||
|
||||
// applyDiffLines applies the given change lines of one file — a line, a hunk, a range —
|
||||
|
||||
+26
-11
@@ -82,6 +82,9 @@ type Gui struct {
|
||||
statusManager *status.StatusManager
|
||||
waitForIntro sync.WaitGroup
|
||||
viewBufferManagerMap map[string]*tasks.ViewBufferManager
|
||||
// holds a mapping of the main section's view names to the writers that link the
|
||||
// files named in the diffstat of what is rendered into them
|
||||
diffStatLinkWriterMap map[string]*helpers.DiffStatLinkWriter
|
||||
// holds a mapping of view names to ptmx's. This is for rendering command outputs
|
||||
// from within a pty. The point of keeping track of them is so that if we re-size
|
||||
// the window, we can tell the pty it needs to resize accordingly.
|
||||
@@ -415,6 +418,17 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
|
||||
return gui.helpers.Files.EditFiles([]string{filepath})
|
||||
}
|
||||
|
||||
if entry, ok := strings.CutPrefix(url, helpers.DiffStatLinkScheme); ok {
|
||||
view, err := gui.g.View(viewname)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if pane := gui.mainContextForView(view); pane != nil {
|
||||
gui.helpers.DiffLine.JumpToFileNamedInDiffStat(pane, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := gui.os.OpenLink(url); err != nil {
|
||||
return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
|
||||
}
|
||||
@@ -775,17 +789,18 @@ func NewGui(
|
||||
test integrationTypes.IntegrationTest,
|
||||
) (*Gui, error) {
|
||||
gui := &Gui{
|
||||
Common: cmn,
|
||||
gitVersion: gitVersion,
|
||||
Config: configurer,
|
||||
Updater: updater,
|
||||
statusManager: status.NewStatusManager(),
|
||||
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
|
||||
viewPtmxMap: map[string]oscommands.Pty{},
|
||||
showRecentRepos: showRecentRepos,
|
||||
RepoPathStack: &utils.Stack[types.RepoLocation]{},
|
||||
RepoStateMap: map[Repo]*GuiRepoState{},
|
||||
GuiLog: []string{},
|
||||
Common: cmn,
|
||||
gitVersion: gitVersion,
|
||||
Config: configurer,
|
||||
Updater: updater,
|
||||
statusManager: status.NewStatusManager(),
|
||||
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
|
||||
diffStatLinkWriterMap: map[string]*helpers.DiffStatLinkWriter{},
|
||||
viewPtmxMap: map[string]oscommands.Pty{},
|
||||
showRecentRepos: showRecentRepos,
|
||||
RepoPathStack: &utils.Stack[types.RepoLocation]{},
|
||||
RepoStateMap: map[Repo]*GuiRepoState{},
|
||||
GuiLog: []string{},
|
||||
|
||||
// initializing this to true for the time being; it will be reset to the
|
||||
// real value after loading the user config:
|
||||
|
||||
@@ -74,6 +74,12 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
|
||||
// or a log, and reads as badly cut off at the edge of the pane as it would
|
||||
// anywhere else.
|
||||
view.Wrap = !mainContext.ContentIsDiff() || gui.c.UserConfig().Gui.WrapLinesInDiffView
|
||||
// The files named in the diffstat are linked to where their diff begins, over a
|
||||
// render that has both: the panel's own diff, and rows that can be placed in the
|
||||
// files they show. The writer is told here, on the UI thread, since it is asked
|
||||
// on the one reading the command's output.
|
||||
gui.diffStatLinkWriter(view).BeginRender(
|
||||
mainContext.ContentIsDiff() && gui.helpers.DiffLine.DiffRowsCanBePlaced())
|
||||
}
|
||||
|
||||
if err := gui.runTaskForView(view, opts.Task); err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/tasks"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -156,12 +157,34 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentWriter returns what a render of the given view writes its content to: the
|
||||
// view itself, or, for a pane of the main section, the writer that links the files
|
||||
// named in the diffstat on its way there (see DiffStatLinkWriter).
|
||||
func (gui *Gui) contentWriter(view *gocui.View) io.Writer {
|
||||
if gui.mainContextForView(view) == nil {
|
||||
return view
|
||||
}
|
||||
return gui.diffStatLinkWriter(view)
|
||||
}
|
||||
|
||||
// diffStatLinkWriter returns the writer that links the diffstat of the given pane,
|
||||
// making it if the pane hasn't rendered yet. It lasts as long as the view does, and
|
||||
// each render tells it what to make of that render (see DiffStatLinkWriter.BeginRender).
|
||||
func (gui *Gui) diffStatLinkWriter(view *gocui.View) *helpers.DiffStatLinkWriter {
|
||||
writer, ok := gui.diffStatLinkWriterMap[view.Name()]
|
||||
if !ok {
|
||||
writer = helpers.NewDiffStatLinkWriter(view)
|
||||
gui.diffStatLinkWriterMap[view.Name()] = writer
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
manager, ok := gui.viewBufferManagerMap[view.Name()]
|
||||
if !ok {
|
||||
manager = tasks.NewViewBufferManager(
|
||||
gui.Log,
|
||||
view,
|
||||
gui.contentWriter(view),
|
||||
func() {
|
||||
// Called before showing the "loading..." indicator: clear the
|
||||
// displayed buffer so only "loading..." is shown. The actual content
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -213,6 +214,45 @@ const (
|
||||
DiffMainViewTypePatchBuilding
|
||||
)
|
||||
|
||||
// PullRequestDiffContext is implemented by the side panel contexts that show, in
|
||||
// their focused main view, the diff of a commit of a branch: the commits panel and
|
||||
// the sub-commits panel, and the commit files panel entered from either of them. A
|
||||
// pull request for that branch has a view of that commit, so a line of the diff can
|
||||
// be pointed at in it. A panel showing a diff that no pull request has a view of (the
|
||||
// working tree's, a stash entry's, a reflog entry's) doesn't implement this.
|
||||
type PullRequestDiffContext interface {
|
||||
Context
|
||||
|
||||
// PullRequestDiff returns the commits whose combined diff the main view is
|
||||
// showing, and the branch whose pull request would show that diff.
|
||||
PullRequestDiff() PullRequestDiff
|
||||
}
|
||||
|
||||
// PullRequestDiff is the diff of one or more commits of a branch, as the pull request
|
||||
// for that branch shows it.
|
||||
type PullRequestDiff struct {
|
||||
// Branch is the local branch whose pull request would show the diff, and "" where
|
||||
// no branch's would: the panel may have nothing selected, HEAD may be detached, or
|
||||
// what was drilled into may be a tag or a remote branch rather than a local one.
|
||||
// In a stack of branches, each with a pull request of its own, it is the branch
|
||||
// of the stack that the newest of the commits is on.
|
||||
Branch string
|
||||
|
||||
// SpansBranches is true where the commits are on more than one branch of such a
|
||||
// stack. Branch is then the branch of the newest of them, and its pull request
|
||||
// doesn't hold all of them.
|
||||
SpansBranches bool
|
||||
|
||||
// Commits are the commits whose combined diff the main view is showing, newest
|
||||
// first as the panel lists them.
|
||||
Commits []*models.Commit
|
||||
|
||||
// BaseHash is the hash of the commit the diff starts after: the parent of the
|
||||
// oldest of the commits, where the pull request has that parent as one of its own
|
||||
// commits, and "" where the diff starts where the pull request itself does.
|
||||
BaseHash string
|
||||
}
|
||||
|
||||
// DiffPaneContext is one of the two panes the main section can show, as the thing
|
||||
// that holds a diff with a selection in it. The panels that act on such a selection
|
||||
// are handed the pane it was made in, and speak to it through this.
|
||||
|
||||
@@ -293,6 +293,8 @@ type TranslationSet struct {
|
||||
UnsupportedGitService string
|
||||
CopyPullRequestURL string
|
||||
OpenPullRequestInBrowser string
|
||||
OpenPullRequestAtSelectedLine string
|
||||
OpenPullRequestAtSelectedLineTooltip string
|
||||
NoPullRequestForBranch string
|
||||
NoBranchOnRemote string
|
||||
Fetch string
|
||||
@@ -310,6 +312,11 @@ type TranslationSet struct {
|
||||
ToggleSelectHunk string
|
||||
SelectHunk string
|
||||
NothingToSelectInDiff string
|
||||
NotAvailableInDiffingMode string
|
||||
NotAvailableForCustomPatch string
|
||||
CommitNotInPullRequest string
|
||||
CommitsNotInPullRequest string
|
||||
CommitsInSeveralPullRequests string
|
||||
SelectLineByLine string
|
||||
ToggleSelectHunkTooltip string
|
||||
ToggleSelectionForPatch string
|
||||
@@ -410,6 +417,11 @@ type TranslationSet struct {
|
||||
NextHunk string
|
||||
PrevFileInDiff string
|
||||
NextFileInDiff string
|
||||
JumpToFile string
|
||||
JumpToFileInDiff string
|
||||
JumpToFileInDiffTooltip string
|
||||
OnlyOneFileInDiff string
|
||||
NoFileInDiffNamed string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -1467,6 +1479,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
CreatePullRequest: `Create pull request`,
|
||||
CopyPullRequestURL: `Copy pull request URL to clipboard`,
|
||||
OpenPullRequestInBrowser: `Open pull request in browser`,
|
||||
OpenPullRequestAtSelectedLine: `Open pull request at selected line`,
|
||||
OpenPullRequestAtSelectedLineTooltip: "Open the branch's pull request in your browser, at the line the selection is on, so that you can comment on it there. Only pull requests on GitHub are found.",
|
||||
NoPullRequestForBranch: `No pull request found for this branch`,
|
||||
NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`,
|
||||
Fetch: `Fetch`,
|
||||
@@ -1485,6 +1499,11 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
DismissRangeSelect: "Dismiss range select",
|
||||
ToggleSelectHunk: "Toggle hunk selection",
|
||||
NothingToSelectInDiff: "There is nothing to select here",
|
||||
NotAvailableInDiffingMode: "Not available in diffing mode",
|
||||
NotAvailableForCustomPatch: "Not available for the custom patch",
|
||||
CommitNotInPullRequest: "This commit is not part of the pull request",
|
||||
CommitsNotInPullRequest: "Not all of these commits are part of the pull request",
|
||||
CommitsInSeveralPullRequests: "These commits are not all in the same pull request",
|
||||
SelectHunk: "Select hunks",
|
||||
SelectLineByLine: "Select line-by-line",
|
||||
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
|
||||
@@ -1590,6 +1609,11 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
NextHunk: "Go to next hunk",
|
||||
PrevFileInDiff: "Go to previous file",
|
||||
NextFileInDiff: "Go to next file",
|
||||
JumpToFile: "Jump to file",
|
||||
JumpToFileInDiff: "Jump to file in diff",
|
||||
JumpToFileInDiffTooltip: "Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel.",
|
||||
OnlyOneFileInDiff: "There is only one file in this diff",
|
||||
NoFileInDiffNamed: "This diff has no file named '{{.path}}'",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ClickAFileInADiffStatThatComesFirst = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump to a file by clicking its name in a diffstat the renderer's handshake runs into",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// A renderer that announces the protocol and then passes the diff on as it came.
|
||||
// The handshake has no newline after it, so it runs into the first line the
|
||||
// renderer is given — which for the diff of a range of commits is the first
|
||||
// entry of the diffstat, there being no commit above it.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `printf '\033]1717;1\007'; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("aaa.txt", "one\n")
|
||||
shell.Commit("one")
|
||||
shell.CreateFileAndAdd("zzz.txt", "one\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("two")).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectedLines(
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
// Below the line lazygit writes to say what the diff is of, the diff opens with
|
||||
// the diffstat, and the handshake runs into its first entry. That is the one
|
||||
// clicked here.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("Showing diff for range"),
|
||||
Equals(""),
|
||||
Contains("aaa.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("2 files changed"),
|
||||
).
|
||||
Click(2, 2)
|
||||
|
||||
t.Views().Commits().IsFocused()
|
||||
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ClickAFileInTheDiffStat = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump to a file of a commit's diff by clicking the line that names it in the diffstat",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 600)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
// A long file at either end, so that the file jumped to is far below the
|
||||
// diffstat and has a diff under it to scroll past.
|
||||
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
|
||||
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
// The click below is at a line of the diffstat, so the diff has to open with
|
||||
// the lines this expects.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("commit"),
|
||||
Contains("Author:"),
|
||||
Contains("Date:"),
|
||||
Equals(""),
|
||||
Contains("one"),
|
||||
Equals("---"),
|
||||
Contains("aaa.txt"),
|
||||
Contains("dir/bbb.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("3 files changed"),
|
||||
).
|
||||
Click(2, 8)
|
||||
|
||||
// The panel keeps the focus, and the diff goes to the file clicked.
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
|
||||
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// With the pane focused there is a selection to move, and the click moves it to
|
||||
// the file, exactly as picking the file from the menu would.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Universal.GotoTop)
|
||||
|
||||
// The first file's diff begins on screen already, so the view stays where it
|
||||
// is; a jump only scrolls as far as it must once there is a selection to point
|
||||
// at the file with.
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("commit")).
|
||||
Click(2, 6).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/aaa.txt b/aaa.txt"),
|
||||
).
|
||||
TopVisibleLine(Contains("commit")).
|
||||
// The last file's is far below, so that one is scrolled to.
|
||||
Click(2, 8).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/zzz.txt b/zzz.txt"),
|
||||
).
|
||||
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var JumpToAFileOfTheDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump to a file of a commit's diff by picking it from a menu of the diff's files",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
// A short terminal, so that the first file's diff is longer than the part of the
|
||||
// diff that has been read when the menu asks which files there are.
|
||||
Width: 100,
|
||||
Height: 20,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 600)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.CreateFileAndAdd("ccc.txt", "one\n")
|
||||
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
|
||||
// Another long one at the end, so that the file jumped to below has a diff
|
||||
// under it to scroll past and ends up at the top of the view.
|
||||
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("ccc.txt", "two\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// A menu offering the one file of a single-file diff would be a menu with
|
||||
// nothing to choose, so it says what it found instead.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("two")).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Universal.JumpToFile)
|
||||
|
||||
t.ExpectToast(Contains("There is only one file in this diff"))
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
SelectNextItem().
|
||||
SelectedLine(Contains("one")).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Every file of the diff is offered, in the order the diff shows them and by
|
||||
// the path the repo knows them by, including the ones below the part of the
|
||||
// diff that has been read.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Universal.JumpToFile)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Jump to file")).
|
||||
Lines(
|
||||
Equals("aaa.txt"),
|
||||
Equals("ccc.txt"),
|
||||
Equals("dir/bbb.txt"),
|
||||
Equals("zzz.txt"),
|
||||
Equals("Cancel"),
|
||||
).
|
||||
Select(Equals("dir/bbb.txt")).
|
||||
Confirm()
|
||||
|
||||
// The file lands where stepping to it with next-file would leave it: selected,
|
||||
// and at the top of the view.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
TopVisibleLine(Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt"),
|
||||
).
|
||||
Press(keys.Universal.JumpToFile)
|
||||
|
||||
// The menu filters as you type, which is the point of it for a diff of many
|
||||
// files.
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Jump to file")).
|
||||
Filter("ccc").
|
||||
Lines(
|
||||
Equals("ccc.txt"),
|
||||
).
|
||||
Confirm()
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("diff --git a/ccc.txt b/ccc.txt"),
|
||||
).
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
// The panel the diff belongs to offers the same menu, so that a file can be
|
||||
// jumped to without focusing the diff and having to come back out of it.
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
SelectedLine(Contains("one")).
|
||||
Press(keys.Universal.JumpToFile)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Jump to file in diff")).
|
||||
Select(Equals("zzz.txt")).
|
||||
Confirm()
|
||||
|
||||
// Only the diff moves: it goes to the top of the file, while the panel keeps
|
||||
// the focus and its selection.
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
t.Views().Main().
|
||||
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
|
||||
|
||||
// A panel showing something else in the main view — a branch's commit log —
|
||||
// doesn't offer the menu at all, rather than offering it disabled.
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
t.Views().Menu().Content(DoesNotContain("Jump to file"))
|
||||
}).
|
||||
Cancel()
|
||||
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
t.Views().Menu().Content(Contains("Jump to file in diff"))
|
||||
}).
|
||||
Cancel()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
|
||||
)
|
||||
|
||||
var JumpToAFileOnlyOverADiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The menu of the diff's files is offered only while the main view is showing a diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shared.CreateMergeConflictFiles(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// A commit shows its diff in the main view, so the menu is offered over it.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
NavigateToLine(Contains("original")).
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
t.Views().Menu().Content(Contains("Jump to file in diff"))
|
||||
}).
|
||||
Cancel()
|
||||
|
||||
// The pane binds the same key itself, so the global one isn't offered on top of
|
||||
// the pane's while the pane has the focus.
|
||||
t.Views().Commits().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
t.Views().Menu().
|
||||
Content(Contains("Jump to file")).
|
||||
Content(DoesNotContain("Jump to file in diff"))
|
||||
}).
|
||||
Cancel()
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
// Working through a conflicted file gives the main section over to the merge
|
||||
// conflicts view, which is no diff to jump around in. The pane behind it goes
|
||||
// on holding the diff it last rendered, so it is the view on screen that
|
||||
// decides.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
NavigateToLine(Contains("UU file1")).
|
||||
PressEnter()
|
||||
|
||||
t.Views().MergeConflicts().
|
||||
IsFocused().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
t.Views().Menu().Content(DoesNotContain("Jump to file"))
|
||||
}).
|
||||
Cancel()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var NoDiffStatLinksUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The files named in the diffstat are not linked under a diff renderer that says nothing about its rows",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 30,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// An external diff whose output has nothing to say about which line of which
|
||||
// file each row shows. git writes the diffstat itself, so the names are there
|
||||
// to be clicked, but nothing could find the file they name.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Name: "opaque", Type: "extDiff", Command: `sh -c 'echo EXT'`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("aaa.txt", "one\n")
|
||||
shell.CreateFileAndAdd("zzz.txt", "one\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("one"))
|
||||
|
||||
// The click below is at a line of the diffstat, so the diff has to open with
|
||||
// the lines this expects.
|
||||
t.Views().Main().
|
||||
TopLines(
|
||||
Contains("commit"),
|
||||
Contains("Author:"),
|
||||
Contains("Date:"),
|
||||
Equals(""),
|
||||
Contains("one"),
|
||||
Equals("---"),
|
||||
Contains("aaa.txt"),
|
||||
Contains("zzz.txt"),
|
||||
Contains("2 files changed"),
|
||||
).
|
||||
Click(2, 7)
|
||||
|
||||
// The name is no link, so the click is an ordinary one, which focuses the pane
|
||||
// it lands in. Were it a link, it would have been followed instead, and would
|
||||
// have had to report that it found no such file — the test fails on the toast
|
||||
// that leaves unacknowledged.
|
||||
t.Views().Main().IsFocused()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var OpenPullRequestOnlyOverACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Opening a diff line in the pull request is offered over a commit's own diff, and refused over the other diffs the main view shows",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
|
||||
shell.UpdateFile("file1", "one\nTWO\nTHREE\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// The working tree's diff is no commit of a branch, so no pull request has a
|
||||
// view of it and the command isn't offered there at all.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
SelectedLine(Contains("file1")).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Tap(func() {
|
||||
// The command is bound right below the one asserted here, so a menu
|
||||
// showing that one would be showing this one too if it had it.
|
||||
t.Views().Menu().
|
||||
Content(Contains("Go to next file")).
|
||||
Content(DoesNotContain("Open pull request at selected line"))
|
||||
}).
|
||||
Cancel()
|
||||
|
||||
// Over a commit's diff it is offered, and says so where the branch has no pull
|
||||
// request to open.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("second commit")).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Commits.OpenPullRequestInBrowser)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("No pull request found for this branch")).
|
||||
Confirm()
|
||||
|
||||
// The pane previewing the custom patch shows the patch's lines at the numbers
|
||||
// the patch gives them, which are not the ones the pull request shows.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Commits.OpenPullRequestInBrowser)
|
||||
|
||||
t.ExpectToast(Contains("Not available for the custom patch"))
|
||||
|
||||
// In diffing mode the main view shows a diff against another ref rather than
|
||||
// the commit's own, and the pull request has no view of that either.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.DiffingMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Diffing")).
|
||||
Select(MatchesRegexp(`Diff \w+`)).
|
||||
Confirm()
|
||||
|
||||
t.Views().Commits().
|
||||
SelectNextItem().
|
||||
SelectedLine(Contains("first commit")).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Commits.OpenPullRequestInBrowser)
|
||||
|
||||
t.ExpectToast(Contains("Not available in diffing mode"))
|
||||
},
|
||||
})
|
||||
@@ -378,6 +378,8 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.BuildPatchWithMixedSelections,
|
||||
main_view.ChangeContextSizeWhileBuildingPatch,
|
||||
main_view.ChangeScreenModeInFocusedDiff,
|
||||
main_view.ClickAFileInADiffStatThatComesFirst,
|
||||
main_view.ClickAFileInTheDiffStat,
|
||||
main_view.ClickSelectsDiffLine,
|
||||
main_view.CommitFromMainView,
|
||||
main_view.CopyRowsThatAreNoDiffLine,
|
||||
@@ -405,6 +407,8 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.FocusLeavesAnAlwaysSplitEmptyPane,
|
||||
main_view.FocusReturnsWhenSplitCollapses,
|
||||
main_view.HideSelectionWhenChangesVanish,
|
||||
main_view.JumpToAFileOfTheDiff,
|
||||
main_view.JumpToAFileOnlyOverADiff,
|
||||
main_view.KeepAWrappedLineCoveredAcrossARerender,
|
||||
main_view.KeepBothHalvesOfAChangeSelected,
|
||||
main_view.KeepPositionByTheVisibleEndOfASelection,
|
||||
@@ -440,10 +444,12 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.MovePatchToNewCommitBefore,
|
||||
main_view.MovePatchToNewCommitInStackedBranch,
|
||||
main_view.NavigateByHunkAndFile,
|
||||
main_view.NoDiffStatLinksUnderAnExternalDiff,
|
||||
main_view.NoSelectionOverABinaryDiff,
|
||||
main_view.NoSelectionOverACommitLog,
|
||||
main_view.NoSelectionOverAConflictHint,
|
||||
main_view.NoSelectionWhenNoChanges,
|
||||
main_view.OpenPullRequestOnlyOverACommitsDiff,
|
||||
main_view.PatchMarksFollowARendererSwitch,
|
||||
main_view.PatchMarksShowWheneverTheirDiffIsOnScreen,
|
||||
main_view.RangeSelectDiffLines,
|
||||
|
||||
@@ -3138,6 +3138,20 @@
|
||||
],
|
||||
"default": "0"
|
||||
},
|
||||
"jumpToFile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "\u003cctrl+g\u003e"
|
||||
},
|
||||
"nextMatch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user