mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-02 12:06:48 -04:00
Compare commits
112
Commits
@@ -300,18 +300,6 @@ gui:
|
||||
# NerdFontsVersion is not empty.
|
||||
showFileIcons: true
|
||||
|
||||
# How the commit graph is drawn.
|
||||
# One of: 'auto' (default) | 'classic' | 'detailed'
|
||||
# 'detailed' connects the lines to the commit circles, and shows exactly where
|
||||
# branches fork off and merge. It draws the graph with the git branch drawing
|
||||
# symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself:
|
||||
# kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with
|
||||
# GPU acceleration. Other terminals need a font that contains them, such as
|
||||
# https://github.com/rbong/flog-symbols.
|
||||
# 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws
|
||||
# these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
commitGraphStyle: auto
|
||||
|
||||
# Length of author name in (non-expanded) commits view. 2 means show initials
|
||||
# only.
|
||||
commitAuthorShortLength: 2
|
||||
@@ -709,7 +697,6 @@ keybinding:
|
||||
- "4"
|
||||
- "5"
|
||||
focusMainView: "0"
|
||||
jumpToFile: <ctrl+g>
|
||||
nextMatch: "n"
|
||||
prevMatch: "N"
|
||||
startSearch: /
|
||||
@@ -951,8 +938,6 @@ It is used, for example, when pasting a commit message into the commit message p
|
||||
|
||||
There are two commands for opening files, `o` for "open" and `e` for "edit". `o` acts as if the file was double-clicked in the Finder/Explorer, so it also works for non-text files, whereas `e` opens the file in an editor. `e` can also jump to the right line in the file when you invoke it from a focused diff.
|
||||
|
||||
You can also open a line in your editor with the mouse: alt-click or shift-click it. Both modifiers do the same thing, because some terminals only support one or the other. The click leaves the focus and the selection where they are, so it works while you are reading a diff from another panel, or while a popup is open.
|
||||
|
||||
To tell lazygit which editor to use for the `e` command, the easiest way to do that is to provide an editPreset config, e.g.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -24,7 +24,6 @@ _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. |
|
||||
@@ -235,8 +234,6 @@ _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 | |
|
||||
@@ -319,8 +316,6 @@ _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,7 +24,6 @@ _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> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
|
||||
@@ -204,8 +203,6 @@ _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フックなしで変更をコミット | |
|
||||
@@ -296,8 +293,6 @@ _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,7 +24,6 @@ _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. |
|
||||
@@ -96,8 +95,6 @@ _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 | |
|
||||
@@ -191,8 +188,6 @@ _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,7 +24,6 @@ _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. |
|
||||
@@ -243,8 +242,6 @@ _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 | |
|
||||
@@ -319,8 +316,6 @@ _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,7 +24,6 @@ _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. |
|
||||
@@ -111,8 +110,6 @@ _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 | |
|
||||
@@ -214,8 +211,6 @@ _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,7 +24,6 @@ _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. |
|
||||
@@ -247,8 +246,6 @@ _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 | |
|
||||
@@ -328,8 +325,6 @@ _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,7 +24,6 @@ _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. |
|
||||
@@ -86,8 +85,6 @@ _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 `` | Закоммитить изменения без предварительного хука коммита | |
|
||||
@@ -113,8 +110,6 @@ _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,7 +24,6 @@ _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> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
|
||||
@@ -282,8 +281,6 @@ _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 `` | 提交变更而无需预先提交钩子 | |
|
||||
@@ -325,8 +322,6 @@ _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,7 +24,6 @@ _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> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
|
||||
@@ -71,8 +70,6 @@ _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 就提交更改 | |
|
||||
@@ -347,8 +344,6 @@ _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 就提交更改 | |
|
||||
|
||||
@@ -179,11 +179,6 @@ type GuiConfig struct {
|
||||
NerdFontsVersion string `yaml:"nerdFontsVersion" jsonschema:"enum=2,enum=3,enum="`
|
||||
// If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
|
||||
ShowFileIcons bool `yaml:"showFileIcons"`
|
||||
// How the commit graph is drawn.
|
||||
// One of: 'auto' (default) | 'classic' | 'detailed'
|
||||
// 'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.
|
||||
// 'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.
|
||||
CommitGraphStyle string `yaml:"commitGraphStyle" jsonschema:"enum=auto,enum=classic,enum=detailed"`
|
||||
// Length of author name in (non-expanded) commits view. 2 means show initials only.
|
||||
CommitAuthorShortLength int `yaml:"commitAuthorShortLength"`
|
||||
// Length of author name in expanded commits view. 2 means show initials only.
|
||||
@@ -516,7 +511,6 @@ 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"`
|
||||
@@ -930,7 +924,6 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
ShowIcons: false,
|
||||
NerdFontsVersion: "",
|
||||
ShowFileIcons: true,
|
||||
CommitGraphStyle: "auto",
|
||||
CommitAuthorShortLength: 2,
|
||||
CommitAuthorLongLength: 17,
|
||||
CommitHashLength: 8,
|
||||
@@ -1044,7 +1037,6 @@ 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{"/"},
|
||||
|
||||
@@ -30,10 +30,6 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.commitGraphStyle", config.Gui.CommitGraphStyle,
|
||||
[]string{"auto", "classic", "detailed"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
|
||||
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -32,37 +32,3 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []bool{false, true}, doubleClicks)
|
||||
}
|
||||
|
||||
func TestASwallowedClickIsNoHalfOfADoubleClick(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
doubleClicks := []bool{}
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
press := gocuiEventFromTcellEvent(
|
||||
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone))
|
||||
release := gocuiEventFromTcellEvent(
|
||||
tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone))
|
||||
|
||||
// A click the client rejects, as it does for one landing behind a popup panel.
|
||||
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
|
||||
assert.NoError(t, g.onKey(&press))
|
||||
assert.NoError(t, g.onKey(&release))
|
||||
assert.Empty(t, doubleClicks)
|
||||
|
||||
// The same spot clicked again once clicks are accepted. It is a click of its
|
||||
// own, not the second half of the one that nothing acted on.
|
||||
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return true }
|
||||
assert.NoError(t, g.onKey(&press))
|
||||
|
||||
assert.Equal(t, []bool{false}, doubleClicks)
|
||||
}
|
||||
|
||||
+5
-22
@@ -64,8 +64,8 @@ func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {
|
||||
assert.True(t, status.IsTainted(), "status view should be tainted after SetContent")
|
||||
assert.False(t, main.IsTainted(), "main view should not be tainted (was not modified)")
|
||||
|
||||
// flushContentOnly should clear status tainted flag
|
||||
g.flushContentOnly(g.views)
|
||||
// flushContentOnly should succeed and clear status tainted flag
|
||||
assert.NoError(t, g.flushContentOnly(g.views))
|
||||
|
||||
assert.False(t, status.IsTainted(), "status view should not be tainted after flushContentOnly")
|
||||
assert.False(t, main.IsTainted(), "main view should not be tainted after flushContentOnly")
|
||||
@@ -76,28 +76,11 @@ func TestFlushContentOnly_WritesCorrectContent(t *testing.T) {
|
||||
status, _ := setupViews(t, g)
|
||||
|
||||
status.SetContent("Fetching |")
|
||||
g.flushContentOnly(g.views)
|
||||
assert.NoError(t, g.flushContentOnly(g.views))
|
||||
|
||||
assert.Equal(t, "Fetching |", status.Buffer())
|
||||
}
|
||||
|
||||
func TestForceFlushViewsContentOnlyDrawsLineFlash(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
_, main := setupViews(t, g)
|
||||
main.Highlight = true
|
||||
main.SelBgColor = ColorBlue
|
||||
main.SelectedLineColorWidth = 2
|
||||
main.FocusPoint(0, 0, false)
|
||||
|
||||
main.SetLineFlash(0)
|
||||
g.ForceFlushViewsContentOnly(g.Views())
|
||||
|
||||
for x := main.x0 + 1; x <= main.x0+2; x++ {
|
||||
_, style, _ := Screen.Get(x, main.y0+1)
|
||||
assert.True(t, style.HasReverse(), "selection-bar cell at x=%d should flash", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
status, main := setupViews(t, g)
|
||||
@@ -248,7 +231,7 @@ func TestFlushContentOnly_DoesNotOverdrawHigherZViews(t *testing.T) {
|
||||
assert.False(t, popup.IsTainted(), "popup should not be tainted")
|
||||
|
||||
// flushContentOnly is what spinner ticks ultimately invoke.
|
||||
g.flushContentOnly(g.views)
|
||||
assert.NoError(t, g.flushContentOnly(g.views))
|
||||
|
||||
assert.Equal(t, "P", cellAt(21, 9),
|
||||
"popup region must still show popup content after flushContentOnly; "+
|
||||
@@ -296,7 +279,7 @@ func TestFlushContentOnly_RedrawsTransitivelyOverlappingViews(t *testing.T) {
|
||||
assert.False(t, b.IsTainted())
|
||||
assert.False(t, c.IsTainted())
|
||||
|
||||
g.flushContentOnly(g.views)
|
||||
assert.NoError(t, g.flushContentOnly(g.views))
|
||||
|
||||
// a redrawn (direct).
|
||||
assert.Equal(t, "X", cellAt(5, 5), "a should be redrawn (tainted)")
|
||||
|
||||
+110
-117
@@ -91,14 +91,6 @@ type ViewMouseBinding struct {
|
||||
|
||||
// must be a mouse key
|
||||
Key KeyName
|
||||
|
||||
// If true, this binding is dispatched before ShouldHandleMouseEvent is
|
||||
// consulted, so it fires even when a popup panel is focused and the click
|
||||
// lands on a view other than that panel (which is normally swallowed). This
|
||||
// is the same early phase that hyperlink clicks are handled in; use it for
|
||||
// clicks that must stay live behind a popup, e.g. opening a diff line in the
|
||||
// editor from the main view behind the commit-message panel.
|
||||
HandleWhenPopupPanelFocused bool
|
||||
}
|
||||
|
||||
type ViewMouseBindingOpts struct {
|
||||
@@ -421,12 +413,13 @@ func (g *Gui) Size() (x, y int) {
|
||||
// corner of the terminal. It checks if the position is valid and applies
|
||||
// the given colors.
|
||||
// Should only be used if you know that the given rune is not part of a grapheme cluster.
|
||||
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error {
|
||||
if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY {
|
||||
// swallowing error because it's not that big of a deal
|
||||
return
|
||||
return nil
|
||||
}
|
||||
tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetView creates a new view with its top-left corner at (x0, y0)
|
||||
@@ -743,12 +736,6 @@ func (g *Gui) DetectedColorScheme() DetectedColorScheme {
|
||||
return g.colorScheme
|
||||
}
|
||||
|
||||
// Terminal returns the name and version that the terminal reported at
|
||||
// startup. Both are empty if it didn't report them.
|
||||
func (g *Gui) Terminal() (string, string) {
|
||||
return g.screen.Terminal()
|
||||
}
|
||||
|
||||
// SetColorSchemeChangeHandler sets a function to call on the UI thread whenever
|
||||
// the terminal's colors change after startup.
|
||||
func (g *Gui) SetColorSchemeChangeHandler(handler func(DetectedColorScheme) error) {
|
||||
@@ -1202,8 +1189,7 @@ func (g *Gui) processEvent() error {
|
||||
contentOnly = contentOnly && remainingContentOnly
|
||||
|
||||
if contentOnly {
|
||||
g.flushContentOnly(g.views)
|
||||
return nil
|
||||
return g.flushContentOnly(g.views)
|
||||
}
|
||||
return g.flush()
|
||||
}
|
||||
@@ -1304,7 +1290,7 @@ func (g *Gui) onResize() {
|
||||
}
|
||||
|
||||
// drawFrameEdges draws the horizontal and vertical edges of a view.
|
||||
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) error {
|
||||
runeH, runeV := '─', '│'
|
||||
if len(v.FrameRunes) >= 2 {
|
||||
runeH, runeV = v.FrameRunes[0], v.FrameRunes[1]
|
||||
@@ -1315,10 +1301,14 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
|
||||
continue
|
||||
}
|
||||
if v.y0 > -1 && v.y0 < g.maxY {
|
||||
g.SetRune(x, v.y0, runeH, fgColor, bgColor)
|
||||
if err := g.SetRune(x, v.y0, runeH, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v.y1 > -1 && v.y1 < g.maxY {
|
||||
g.SetRune(x, v.y1, runeH, fgColor, bgColor)
|
||||
if err := g.SetRune(x, v.y1, runeH, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,14 +1318,19 @@ func (g *Gui) drawFrameEdges(v *View, fgColor, bgColor Attribute) {
|
||||
continue
|
||||
}
|
||||
if v.x0 > -1 && v.x0 < g.maxX {
|
||||
g.SetRune(v.x0, y, runeV, fgColor, bgColor)
|
||||
if err := g.SetRune(v.x0, y, runeV, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v.x1 > -1 && v.x1 < g.maxX {
|
||||
runeToPrint := calcScrollbarRune(showScrollbar, realScrollbarStart, realScrollbarEnd, y, runeV)
|
||||
|
||||
g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor)
|
||||
if err := g.SetRune(v.x1, y, runeToPrint, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calcScrollbarRune(
|
||||
@@ -1435,13 +1430,17 @@ func corner(v *View, directions byte) rune {
|
||||
}
|
||||
|
||||
// drawFrameCorners draws the corners of the view.
|
||||
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) error {
|
||||
if v.y0 == v.y1 {
|
||||
if !g.SupportOverlaps && v.x0 >= 0 && v.x1 >= 0 && v.y0 >= 0 && v.x0 < g.maxX && v.x1 < g.maxX && v.y0 < g.maxY {
|
||||
g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor)
|
||||
g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor)
|
||||
if err := g.SetRune(v.x0, v.y0, '╶', fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.SetRune(v.x1, v.y0, '╴', fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
runeTL, runeTR, runeBL, runeBR := '┌', '┐', '└', '┘'
|
||||
@@ -1462,15 +1461,18 @@ func (g *Gui) drawFrameCorners(v *View, fgColor, bgColor Attribute) {
|
||||
|
||||
for _, c := range corners {
|
||||
if c.x >= 0 && c.y >= 0 && c.x < g.maxX && c.y < g.maxY {
|
||||
g.SetRune(c.x, c.y, c.ch, fgColor, bgColor)
|
||||
if err := g.SetRune(c.x, c.y, c.ch, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawTitle draws the title of the view.
|
||||
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
|
||||
if v.y0 < 0 || v.y0 >= g.maxY {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
tabs := v.Tabs
|
||||
@@ -1506,7 +1508,9 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
|
||||
|
||||
x := v.x0 + 2
|
||||
for _, ch := range prefix {
|
||||
g.SetRune(x, v.y0, ch, fgColor, bgColor)
|
||||
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
x += uniseg.StringWidth(string(ch))
|
||||
}
|
||||
for i, ch := range str {
|
||||
@@ -1529,55 +1533,64 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) {
|
||||
currentFgColor &= ^AttrBold
|
||||
}
|
||||
}
|
||||
g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor)
|
||||
if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
x += uniseg.StringWidth(string(ch))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawSubtitle draws the subtitle of the view.
|
||||
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
|
||||
if v.y0 < 0 || v.y0 >= g.maxY {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle)
|
||||
if start < v.x0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
x := start
|
||||
for _, ch := range v.Subtitle {
|
||||
if x >= v.x1 {
|
||||
break
|
||||
}
|
||||
g.SetRune(x, v.y0, ch, fgColor, bgColor)
|
||||
if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
x += uniseg.StringWidth(string(ch))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) {
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
message := v.Footer
|
||||
|
||||
if v.y1 < 0 || v.y1 >= g.maxY {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
start := v.x1 - 1 - uniseg.StringWidth(message)
|
||||
if start < v.x0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
x := start
|
||||
for _, ch := range message {
|
||||
if x >= v.x1 {
|
||||
break
|
||||
}
|
||||
g.SetRune(x, v.y1, ch, fgColor, bgColor)
|
||||
if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
x += uniseg.StringWidth(string(ch))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flush updates the gui, re-drawing frames and buffers.
|
||||
@@ -1605,35 +1618,40 @@ func (g *Gui) flush() error {
|
||||
}
|
||||
}
|
||||
for _, v := range g.views {
|
||||
g.draw(v)
|
||||
if err := g.draw(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
Screen.Show()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Redraws only dirty views and skips the layout pass.
|
||||
// Redraws only tainted views and skips the layout pass.
|
||||
// tcell's cell-level dirty tracking ensures only
|
||||
// actually-changed cells are emitted to the terminal.
|
||||
// Will also redraw any views that overlap dirty views.
|
||||
func (g *Gui) flushContentOnly(views []*View) {
|
||||
// Will also redraw any views that overlap tainted views
|
||||
func (g *Gui) flushContentOnly(views []*View) error {
|
||||
// The screen must not be touched while suspended (see Suspend).
|
||||
if g.isSuspended() {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, v := range viewsToRedrawContentOnly(views) {
|
||||
g.draw(v)
|
||||
if err := g.draw(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
Screen.Show()
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewsToRedrawContentOnly(views []*View) []*View {
|
||||
redrawIndexes := set.New[int]()
|
||||
|
||||
for i, v := range views {
|
||||
if !v.NeedsRedraw() && !redrawIndexes.Includes(i) {
|
||||
if !v.IsTainted() && !redrawIndexes.Includes(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1663,11 +1681,11 @@ func (g *Gui) ForceLayoutAndRedraw() error {
|
||||
return g.flush()
|
||||
}
|
||||
|
||||
// Redraws only dirty views outside of the normal main
|
||||
// Redraws only tainted views outside of the normal main
|
||||
// loop, without a layout pass. Useful during longer operations that block the
|
||||
// main thread, e.g. to update a spinner in a status view.
|
||||
func (g *Gui) ForceFlushViewsContentOnly(views []*View) {
|
||||
g.flushContentOnly(views)
|
||||
func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
|
||||
return g.flushContentOnly(views)
|
||||
}
|
||||
|
||||
// hasFocus reports whether a view is drawn as focused. Views that are embedded
|
||||
@@ -1685,9 +1703,9 @@ func outermostView(v *View) *View {
|
||||
}
|
||||
|
||||
// draw manages the cursor and calls the draw function of a view.
|
||||
func (g *Gui) draw(v *View) {
|
||||
func (g *Gui) draw(v *View) error {
|
||||
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.Cursor {
|
||||
@@ -1726,18 +1744,30 @@ func (g *Gui) draw(v *View) {
|
||||
}
|
||||
}
|
||||
|
||||
g.drawFrameEdges(v, frameColor, bgColor)
|
||||
g.drawFrameCorners(v, frameColor, bgColor)
|
||||
if err := g.drawFrameEdges(v, frameColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.drawFrameCorners(v, frameColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Title != "" || len(v.Tabs) > 0 {
|
||||
g.drawTitle(v, fgColor, bgColor)
|
||||
if err := g.drawTitle(v, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v.Subtitle != "" {
|
||||
g.drawSubtitle(v, fgColor, bgColor)
|
||||
if err := g.drawSubtitle(v, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v.Footer != "" && g.ShowListFooter {
|
||||
g.drawListFooter(v, fgColor, bgColor)
|
||||
if err := g.drawListFooter(v, fgColor, bgColor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onKey manages key-press events. A keybinding handler is called when
|
||||
@@ -1821,25 +1851,6 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
var mouseOpts ViewMouseBindingOpts
|
||||
if IsMouseKey(ev.Key) {
|
||||
mouseOpts = ViewMouseBindingOpts{
|
||||
X: newX, Y: newY, Key: ev.Key.KeyName(),
|
||||
IsDoubleClick: g.isDoubleClick(newX, newY, ev.Key.KeyName(), v),
|
||||
}
|
||||
|
||||
// Dispatch bindings that opt into firing while a popup panel is focused
|
||||
// before the gate below gets a chance to reject the click.
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matched {
|
||||
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if g.ShouldHandleMouseEvent != nil {
|
||||
if !g.ShouldHandleMouseEvent(v, ev.Key.KeyName()) {
|
||||
// Give clients a chance to reject clicks, for example clicks in inactive views
|
||||
@@ -1847,21 +1858,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Bindings match modifiers exactly, so a gesture with a keyboard modifier held
|
||||
// is a gesture of its own, and the bindings that act on a plain click pass it
|
||||
// by. It therefore has to leave the view as it found it: the cursor stays where
|
||||
// the selection is (a list view draws its selection at the cursor), and no
|
||||
// mouse capture begins for a drag that no binding will extend. ModMotion comes
|
||||
// from the mouse rather than the keyboard, and every drag carries it, so it is
|
||||
// masked out here.
|
||||
gestureIsModified := ev.Key.Mod()&^ModMotion != ModNone
|
||||
|
||||
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod() == ModNone {
|
||||
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
|
||||
g.captureMouse(v)
|
||||
}
|
||||
|
||||
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease &&
|
||||
!gestureIsModified {
|
||||
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
|
||||
cursorX, cursorY := newCx, newCy
|
||||
// A captured drag can report positions outside the view; keep the
|
||||
// view cursor inside its bounds in that case. Handlers still get
|
||||
@@ -1899,8 +1900,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
|
||||
if IsMouseKey(ev.Key) {
|
||||
g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
matched, err := g.execMouseKeybindings(v, ev, mouseOpts, false)
|
||||
isDoubleClick := g.recordClickInfo(newX, newY, ev.Key.KeyName(), v)
|
||||
opts := ViewMouseBindingOpts{X: newX, Y: newY, Key: ev.Key.KeyName(), IsDoubleClick: isDoubleClick}
|
||||
matched, err := g.execMouseKeybindings(v, ev, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1932,49 +1934,43 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDoubleClick reports whether this click follows one just like it, closely
|
||||
// enough in time to count as a double click.
|
||||
func (g *Gui) isDoubleClick(x, y int, key KeyName, v *View) bool {
|
||||
return g.lastClick != nil &&
|
||||
!IsMouseScrollKey(key) &&
|
||||
key != MouseRelease &&
|
||||
x == g.lastClick.x &&
|
||||
y == g.lastClick.y &&
|
||||
key == g.lastClick.key &&
|
||||
v.Name() == g.lastClick.viewName &&
|
||||
time.Now().Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
|
||||
}
|
||||
|
||||
// recordClickInfo remembers this click as the one a following click is compared
|
||||
// against. Only the clicks that reach a binding are recorded, so a click the
|
||||
// client rejects leaves double-click detection where it was.
|
||||
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) {
|
||||
// remember the information for this click, and return true if it was a double click
|
||||
func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
|
||||
if IsMouseScrollKey(key) {
|
||||
g.lastClick = nil
|
||||
return
|
||||
return false
|
||||
}
|
||||
// A release ends a gesture but is not a click of its own; it must leave
|
||||
// the click info of the press that started it alone, or no double click
|
||||
// could ever be detected.
|
||||
if key == MouseRelease {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
g.lastClick = &clickInfo{
|
||||
clickInfo := &clickInfo{
|
||||
x: x,
|
||||
y: y,
|
||||
key: key,
|
||||
viewName: v.Name(),
|
||||
time: time.Now(),
|
||||
}
|
||||
|
||||
isDoubleClick := g.lastClick != nil &&
|
||||
clickInfo.x == g.lastClick.x &&
|
||||
clickInfo.y == g.lastClick.y &&
|
||||
clickInfo.key == g.lastClick.key &&
|
||||
clickInfo.viewName == g.lastClick.viewName &&
|
||||
clickInfo.time.Before(g.lastClick.time.Add(DOUBLE_CLICK_THRESHOLD))
|
||||
|
||||
g.lastClick = clickInfo
|
||||
return isDoubleClick
|
||||
}
|
||||
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts, handleWhenPopupPanelFocused bool) (bool, error) {
|
||||
func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBindingOpts) (bool, error) {
|
||||
isMatch := func(binding *ViewMouseBinding) bool {
|
||||
return binding.ViewName == view.Name() &&
|
||||
ev.Key.KeyName() == binding.Key &&
|
||||
ev.Key.Mod() == binding.Modifier &&
|
||||
binding.HandleWhenPopupPanelFocused == handleWhenPopupPanelFocused
|
||||
ev.Key.Mod() == binding.Modifier
|
||||
}
|
||||
|
||||
// first pass looks for ones that match the focused view
|
||||
@@ -2149,9 +2145,6 @@ func (g *Gui) Suspend() error {
|
||||
return errors.New("Already suspended")
|
||||
}
|
||||
|
||||
for _, view := range g.views {
|
||||
view.ClearLineFlash()
|
||||
}
|
||||
g.suspended = true
|
||||
|
||||
if err := g.screen.Suspend(); err != nil {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAModifiedClickNoBindingWantsLeavesTheViewAlone(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
|
||||
clicks := 0
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(ViewMouseBindingOpts) error {
|
||||
clicks++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
click := func(y int, modifier tcell.ModMask) {
|
||||
for _, button := range []tcell.ButtonMask{tcell.ButtonPrimary, tcell.ButtonNone} {
|
||||
event := gocuiEventFromTcellEvent(
|
||||
tcell.NewEventMouse(view.x0+1, y, button, modifier))
|
||||
assert.NoError(t, g.onKey(&event))
|
||||
}
|
||||
}
|
||||
|
||||
// A plain click is the binding's, and moves the cursor it acts on.
|
||||
click(view.y0+4, tcell.ModNone)
|
||||
assert.Equal(t, 1, clicks)
|
||||
assert.Equal(t, 3, view.CursorY())
|
||||
|
||||
// An alt-click is nobody's here, bindings matching modifiers exactly. It has to
|
||||
// leave the cursor where the selection is, or a list view would draw its
|
||||
// selection on a line its owner never selected.
|
||||
click(view.y0+8, tcell.ModAlt)
|
||||
assert.Equal(t, 1, clicks)
|
||||
assert.Equal(t, 3, view.CursorY())
|
||||
assert.Nil(t, g.mouseCapture, "and no drag begins for it either")
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestOnlyBindingsThatOptedInFireBehindAFocusedPopup(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
_, _ = g.SetView("main", 0, 0, 20, 10, 0)
|
||||
|
||||
fired := []string{}
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "main",
|
||||
Key: MouseLeft,
|
||||
Modifier: ModAlt,
|
||||
HandleWhenPopupPanelFocused: true,
|
||||
Handler: func(ViewMouseBindingOpts) error {
|
||||
fired = append(fired, "opted in")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "main",
|
||||
Key: MouseLeft,
|
||||
Handler: func(ViewMouseBindingOpts) error {
|
||||
fired = append(fired, "ordinary")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
// This is how a client reports that a popup panel has the focus and the click
|
||||
// landed on a view behind it.
|
||||
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse, MouseX: 3, MouseY: 4,
|
||||
Key: NewKey(MouseLeft, "", ModAlt),
|
||||
}))
|
||||
assert.Equal(t, []string{"opted in"}, fired)
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse, MouseX: 3, MouseY: 4,
|
||||
Key: NewKey(MouseLeft, "", ModNone),
|
||||
}))
|
||||
assert.Equal(t, []string{"opted in"}, fired,
|
||||
"a binding that didn't opt in must still be swallowed")
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestFlushIsNoOpWhileSuspended(t *testing.T) {
|
||||
flush func(g *Gui) error
|
||||
}{
|
||||
{"flush", func(g *Gui) error { return g.flush() }},
|
||||
{"flushContentOnly", func(g *Gui) error { g.flushContentOnly(g.views); return nil }},
|
||||
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -67,14 +67,3 @@ func TestResumeSchedulesRedraw(t *testing.T) {
|
||||
assert.Equal(t, eventResize, ev.Type,
|
||||
"resuming must schedule a redraw; without one the screen stays blank until the next event arrives")
|
||||
}
|
||||
|
||||
func TestSuspendClearsLineFlashes(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
v, err := g.SetView("main", 0, 0, 20, 10, 0)
|
||||
assert.ErrorIs(t, err, ErrUnknownView)
|
||||
v.SetLineFlash(3)
|
||||
|
||||
assert.NoError(t, g.Suspend())
|
||||
assert.Equal(t, -1, v.lineFlashY)
|
||||
assert.NoError(t, g.Resume())
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ const (
|
||||
|
||||
var (
|
||||
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
||||
lastMouseMod tcell.ModMask = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
@@ -378,12 +377,6 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
|
||||
newButtonPress = true
|
||||
lastMouseKey = button
|
||||
// The keyboard modifiers held at press time apply to the whole gesture:
|
||||
// the press, every drag event, and the release. Snapshotting them here
|
||||
// keeps a modified press from producing events that match unmodified
|
||||
// bindings, and ignores modifier changes while the button is held.
|
||||
lastMouseMod = tev.Modifiers()
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
switch button {
|
||||
case tcell.ButtonPrimary:
|
||||
mouseKey = MouseLeft
|
||||
@@ -409,8 +402,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
case tcell.ButtonMiddle:
|
||||
default:
|
||||
}
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
lastMouseMod = tcell.ModNone
|
||||
mouseMod = ModNone
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
}
|
||||
default:
|
||||
@@ -441,10 +433,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
||||
// reaches drag bindings instead of being delivered with the
|
||||
// default MouseRelease key.
|
||||
dragState = DRAGGING
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
case DRAGGING:
|
||||
mouseMod = Modifier(lastMouseMod) | ModMotion
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,55 +36,21 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
}
|
||||
|
||||
func TestWholeGestureCarriesPressModifiers(t *testing.T) {
|
||||
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, dragEvent.Type)
|
||||
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt|ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModAlt, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifierChangesWhileButtonHeldAreIgnored(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
|
||||
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestModifiedClickWithoutDragCarriesModifierOnPressAndRelease(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModShift))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonNone, tcell.ModShift))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModShift, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func resetMouseState() {
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
lastMouseMod = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
|
||||
+31
-54
@@ -80,27 +80,18 @@ type View struct {
|
||||
// a user starts a range select and then moves the cursor up.
|
||||
rangeSelectStartY int
|
||||
|
||||
// The view line whose selection-width bar is temporarily reversed. A value
|
||||
// of -1 means that no line is flashing.
|
||||
lineFlashY int
|
||||
|
||||
// readBuffer is used for storing unread bytes
|
||||
readBuffer []byte
|
||||
|
||||
// tained is true if the viewLines must be updated
|
||||
tainted bool
|
||||
|
||||
// needsRedraw is true if the view's current state has not been drawn to the
|
||||
// screen yet. A tainted view always needs a redraw, but draw-only state can
|
||||
// require one without invalidating viewLines.
|
||||
needsRedraw bool
|
||||
|
||||
// firstDirtyLine is the index of the lowest line in `lines` that has been
|
||||
// written to since viewLines was last refreshed, and whose cached wrapping
|
||||
// (lineType.wrappedCells) may therefore be stale. Lines below it are
|
||||
// unchanged and can reuse their cached wrapping instead of being
|
||||
// re-wrapped, which keeps refreshViewLinesIfNeeded cheap while scrolling
|
||||
// appends new lines to a long buffer.
|
||||
// written to or highlighted since viewLines was last refreshed, and whose
|
||||
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
|
||||
// below it are unchanged and can reuse their cached wrapping instead of
|
||||
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
|
||||
// scrolling appends new lines to a long buffer.
|
||||
firstDirtyLine int
|
||||
|
||||
// the last position that the mouse was hovering over; nil if the mouse is outside of
|
||||
@@ -283,18 +274,11 @@ type pos struct {
|
||||
// a view whose size has changed, whose content is the same but has to be wrapped
|
||||
// afresh, call RewrapContent instead.
|
||||
func (v *View) clearViewLines() {
|
||||
v.markViewLinesDirty()
|
||||
v.tainted = true
|
||||
v.viewLines = nil
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
// markViewLinesDirty records that the cached viewLines no longer represent the
|
||||
// view's buffer or wrapping, so both rebuilding and redrawing are required.
|
||||
func (v *View) markViewLinesDirty() {
|
||||
v.tainted = true
|
||||
v.needsRedraw = true
|
||||
}
|
||||
|
||||
// RewrapContent wraps the view's content for the size the view has now, and puts
|
||||
// the positions into that content — the scroll offset, the cursor, a range's
|
||||
// anchor — back on the lines they were on. They are all view lines, which count
|
||||
@@ -809,13 +793,11 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
||||
Frame: true,
|
||||
Editor: DefaultEditor,
|
||||
tainted: true,
|
||||
needsRedraw: true,
|
||||
outMode: mode,
|
||||
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
|
||||
searcher: &searcher{},
|
||||
TextArea: &TextArea{},
|
||||
rangeSelectStartY: -1,
|
||||
lineFlashY: -1,
|
||||
TabWidth: 4,
|
||||
}
|
||||
|
||||
@@ -975,10 +957,6 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
|
||||
fgColor |= AttrUnderline
|
||||
}
|
||||
|
||||
if v.lineFlashY == v.oy+y && (v.SelectedLineColorWidth == 0 || x < v.SelectedLineColorWidth) {
|
||||
fgColor ^= AttrReverse
|
||||
}
|
||||
|
||||
// Don't display empty characters
|
||||
if ch == "" {
|
||||
ch = " "
|
||||
@@ -1199,7 +1177,7 @@ func (v *View) write(p []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
v.markViewLinesDirty()
|
||||
v.tainted = true
|
||||
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
|
||||
// below that stays valid.
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
|
||||
@@ -1634,7 +1612,7 @@ func (v *View) SwapInOffscreenRender() {
|
||||
}
|
||||
v.buf = v.offscreen
|
||||
v.offscreen = nil
|
||||
v.markViewLinesDirty()
|
||||
v.tainted = true
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
@@ -1798,12 +1776,6 @@ func (v *View) IsTainted() bool {
|
||||
return v.tainted
|
||||
}
|
||||
|
||||
func (v *View) NeedsRedraw() bool {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
return v.needsRedraw
|
||||
}
|
||||
|
||||
// draw re-draws the view's contents.
|
||||
func (v *View) draw(isWindowFocused bool) {
|
||||
v.writeMutex.Lock()
|
||||
@@ -1812,7 +1784,6 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
if !v.Visible {
|
||||
return
|
||||
}
|
||||
defer func() { v.needsRedraw = false }()
|
||||
|
||||
v.clearRunes()
|
||||
|
||||
@@ -1937,8 +1908,8 @@ func (v *View) refreshViewLinesIfNeeded() {
|
||||
// Reuse the previously wrapped result for lines that haven't changed
|
||||
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
|
||||
// the current width. Wrapping is expensive and this loop runs on every
|
||||
// scroll event, so only the lines that were actually just read should
|
||||
// be wrapped afresh.
|
||||
// scroll event, so only the lines that were actually just read (or
|
||||
// re-highlighted) should be wrapped afresh.
|
||||
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
|
||||
line.wrappedCells = lineWrap(line.cells, wrap)
|
||||
line.wrappedColumns = wrap
|
||||
@@ -2332,22 +2303,28 @@ func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
|
||||
return fgColor | selTextColor&AttrStyleBits
|
||||
}
|
||||
|
||||
// SetLineFlash temporarily marks a view line without moving or changing the
|
||||
// selection. The caller owns the lifetime and clears it with ClearLineFlash.
|
||||
func (v *View) SetLineFlash(viewLine int) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
// SetHighlight toggles highlighting of separate lines, for custom lists
|
||||
// or multiple selection in views.
|
||||
func (v *View) SetHighlight(y int, on bool) {
|
||||
if y < 0 || y >= len(v.buf.lines) {
|
||||
return
|
||||
}
|
||||
|
||||
v.lineFlashY = viewLine
|
||||
v.needsRedraw = true
|
||||
}
|
||||
|
||||
func (v *View) ClearLineFlash() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.lineFlashY = -1
|
||||
v.needsRedraw = true
|
||||
cells := make([]cell, 0, len(v.buf.lines[y].cells))
|
||||
for _, c := range v.buf.lines[y].cells {
|
||||
if on {
|
||||
c.bgColor = v.SelBgColor
|
||||
c.fgColor = v.SelFgColor
|
||||
} else {
|
||||
c.bgColor = v.BgColor
|
||||
c.fgColor = v.FgColor
|
||||
}
|
||||
cells = append(cells, c)
|
||||
}
|
||||
v.tainted = true
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, y)
|
||||
v.buf.lines[y].cells = cells
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
func lineWrap(line []cell, columns int) [][]cell {
|
||||
|
||||
@@ -1113,32 +1113,6 @@ func TestSelectedLinesOfWrappedContent(t *testing.T) {
|
||||
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
func TestLineFlashReversesTheSelectionBarWithoutChangingSelection(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Highlight = true
|
||||
v.SelBgColor = ColorBlue
|
||||
v.SelectedLineColorWidth = 2
|
||||
v.writeString("one\ntwo\nthree\n")
|
||||
v.FocusPoint(0, 1, false)
|
||||
v.SetLineFlash(1)
|
||||
v.draw(true)
|
||||
|
||||
for x := 1; x <= 2; x++ {
|
||||
_, style, _ := Screen.Get(x, 2)
|
||||
assert.True(t, style.HasReverse(), "selection-bar cell at (%d, 2) should flash", x)
|
||||
}
|
||||
_, style, _ := Screen.Get(3, 2)
|
||||
assert.False(t, style.HasReverse(), "the flash should stop after the selection bar")
|
||||
assert.Equal(t, "two", v.SelectedLine(), "flashing should not change the selection")
|
||||
|
||||
v.ClearLineFlash()
|
||||
v.draw(true)
|
||||
_, style, _ = Screen.Get(1, 2)
|
||||
assert.False(t, style.HasReverse(), "clearing should remove the flash")
|
||||
}
|
||||
|
||||
// Resizing a view throws away the wrapping of its content and wraps it again for
|
||||
// the new width, which moves every line of it to a different view line. The
|
||||
// positions into the view count view lines, so they all have to come along.
|
||||
|
||||
@@ -19,36 +19,16 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
|
||||
)
|
||||
|
||||
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// BranchForPullRequest asks the panel this one was entered from: the files are a
|
||||
// commit's, and which branch's pull request that commit is up for review in is known
|
||||
// there rather than here.
|
||||
func (self *CommitFilesContext) BranchForPullRequest() string {
|
||||
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
|
||||
return parent.BranchForPullRequest()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CommitsForPullRequest asks the panel this one was entered from as well: the files
|
||||
// listed here are those of the commits selected there.
|
||||
func (self *CommitFilesContext) CommitsForPullRequest() ([]*models.Commit, string) {
|
||||
if parent, ok := self.GetParentContext().(types.PullRequestDiffContext); ok {
|
||||
return parent.CommitsForPullRequest()
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
viewModel := filetree.NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return c.Model().CommitFiles },
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/graph"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
@@ -32,70 +31,16 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// BranchForPullRequest returns the checked-out branch: this panel shows its commits,
|
||||
// so a pull request for it is where they are up for review.
|
||||
func (self *LocalCommitsContext) BranchForPullRequest() string {
|
||||
return self.ListContextTrait.c.Model().CheckedOutBranch
|
||||
}
|
||||
|
||||
func (self *LocalCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
|
||||
selectedCommits, _, _ := self.GetSelectedItems()
|
||||
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
|
||||
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
|
||||
}
|
||||
|
||||
// commitsShownInDiff returns 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 two to
|
||||
// DiffHelper.GetUpdateTaskForRenderingCommitsDiff, so anything acting on the diff on
|
||||
// screen acts on the commits that diff is of.
|
||||
func commitsShownInDiff(
|
||||
selectedCommits []*models.Commit, commitAtCursor *models.Commit, refRange *types.RefRange,
|
||||
) []*models.Commit {
|
||||
if refRange != nil {
|
||||
return selectedCommits
|
||||
}
|
||||
if commitAtCursor == nil {
|
||||
return nil
|
||||
}
|
||||
return []*models.Commit{commitAtCursor}
|
||||
}
|
||||
|
||||
// pullRequestBaseForCommits returns the hash of the commit the diff of the given commits
|
||||
// starts after: the parent of the oldest of them. A pull request holds only the commits
|
||||
// of the branch that are pushed, so a parent that isn't pushed is none of its own. The
|
||||
// diff then starts where the pull request itself does, and "" says so.
|
||||
func pullRequestBaseForCommits(allCommits []*models.Commit, commits []*models.Commit) string {
|
||||
if len(commits) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
oldest := commits[len(commits)-1]
|
||||
if oldest.IsFirstCommit() {
|
||||
return ""
|
||||
}
|
||||
|
||||
parentHash := oldest.Parents()[0]
|
||||
parentIsInPullRequest := lo.ContainsBy(allCommits, func(commit *models.Commit) bool {
|
||||
return commit.Hash() == parentHash && commit.Status == models.StatusPushed
|
||||
})
|
||||
if !parentIsInPullRequest {
|
||||
return ""
|
||||
}
|
||||
return parentHash
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
@@ -133,7 +78,6 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
c.Model().BisectInfo,
|
||||
)
|
||||
}
|
||||
@@ -410,18 +354,6 @@ func shouldShowGraph(c *ContextCommon) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func commitGraphSymbolSet(c *ContextCommon) graph.SymbolSet {
|
||||
switch c.UserConfig().Gui.CommitGraphStyle {
|
||||
case "detailed":
|
||||
return graph.BranchDrawingSymbols
|
||||
case "auto":
|
||||
if graph.TerminalDrawsBranchDrawingSymbols(c.GocuiGui().Terminal()) {
|
||||
return graph.BranchDrawingSymbols
|
||||
}
|
||||
}
|
||||
return graph.BoxDrawingSymbols
|
||||
}
|
||||
|
||||
func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPositions []int,
|
||||
modelToViewIndex func(int) int, searchStr string,
|
||||
) []gocui.SearchPosition {
|
||||
|
||||
@@ -4,12 +4,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -55,101 +51,3 @@ func TestAddMovingCommitsIndicator(t *testing.T) {
|
||||
},
|
||||
}, items)
|
||||
}
|
||||
|
||||
func TestCommitsShownInDiff(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
newer := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "newer"})
|
||||
older := models.NewCommit(hashPool, models.NewCommitOpts{Hash: "older"})
|
||||
selected := []*models.Commit{newer, older}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
selectedCommit *models.Commit
|
||||
refRange *types.RefRange
|
||||
expected []*models.Commit
|
||||
}{
|
||||
{
|
||||
name: "a range is diffed as a whole",
|
||||
selectedCommit: newer,
|
||||
refRange: &types.RefRange{From: older, To: newer},
|
||||
expected: selected,
|
||||
},
|
||||
{
|
||||
name: "without a range to diff, only the commit at the cursor is",
|
||||
selectedCommit: newer,
|
||||
expected: []*models.Commit{newer},
|
||||
},
|
||||
{
|
||||
name: "nothing is diffed while nothing is selected",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, commitsShownInDiff(selected, s.selectedCommit, s.refRange))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequestBaseForCommits(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 branch of three pushed commits whose tip was amended, on top of a commit that
|
||||
// is in a main branch already, as the panel lists them: newest first.
|
||||
amended := commit("amended", "third", models.StatusUnpushed)
|
||||
third := commit("third", "second", models.StatusPushed)
|
||||
second := commit("second", "first", models.StatusPushed)
|
||||
first := commit("first", "merged", models.StatusPushed)
|
||||
merged := commit("merged", "ancient", models.StatusMerged)
|
||||
ancient := commit("ancient", "", models.StatusMerged)
|
||||
allCommits := []*models.Commit{amended, third, second, first, merged, ancient}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
commits []*models.Commit
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "a single commit starts after its parent",
|
||||
commits: []*models.Commit{second},
|
||||
expected: "first",
|
||||
},
|
||||
{
|
||||
name: "a range starts after the parent of its oldest commit",
|
||||
commits: []*models.Commit{third, second},
|
||||
expected: "first",
|
||||
},
|
||||
{
|
||||
name: "the pull request's first commit starts where the pull request does",
|
||||
commits: []*models.Commit{first},
|
||||
},
|
||||
{
|
||||
name: "so does a range reaching down to it",
|
||||
commits: []*models.Commit{third, second, first},
|
||||
},
|
||||
{
|
||||
name: "and so does the first commit of the repository",
|
||||
commits: []*models.Commit{ancient},
|
||||
},
|
||||
{
|
||||
name: "a parent the panel doesn't list is none of the pull request's",
|
||||
commits: []*models.Commit{commit("elsewhere", "unlisted", models.StatusPushed)},
|
||||
},
|
||||
{
|
||||
name: "nothing is shown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
assert.Equal(t, s.expected, pullRequestBaseForCommits(allCommits, s.commits))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,33 +21,16 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
_ types.PullRequestDiffContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
// BranchForPullRequest returns the branch this panel was entered from, whose commits it
|
||||
// shows. 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) BranchForPullRequest() string {
|
||||
if branch, ok := self.GetRef().(*models.Branch); ok {
|
||||
return branch.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *SubCommitsContext) CommitsForPullRequest() ([]*models.Commit, string) {
|
||||
selectedCommits, _, _ := self.GetSelectedItems()
|
||||
commits := commitsShownInDiff(selectedCommits, self.GetSelected(), self.GetSelectedRefRangeForDiffFiles())
|
||||
return commits, pullRequestBaseForCommits(self.GetCommits(), commits)
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
@@ -97,7 +80,6 @@ func NewSubCommitsContext(
|
||||
startIdx,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
commitGraphSymbolSet(c),
|
||||
git_commands.NewNullBisectInfo(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func (self *BranchesController) GetOnRenderToMain() func() {
|
||||
rendererTask := types.NewRunDiffRendererTask(cmdObj.GetCmd())
|
||||
task = rendererTask
|
||||
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
|
||||
pr, ok := self.c.Model().PullRequestsMap[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.Helpers().Host.PullRequestForBranch(branch.Name); ok {
|
||||
if pr, ok := self.c.Model().PullRequestsMap[branch.Name]; ok {
|
||||
return pr.Url, nil
|
||||
}
|
||||
|
||||
@@ -855,11 +855,15 @@ func (self *BranchesController) branchIsReal(branch *models.Branch) *types.Disab
|
||||
}
|
||||
|
||||
func (self *BranchesController) branchHasPR(branch *models.Branch) *types.DisabledReason {
|
||||
return self.c.Helpers().Host.NoPullRequestDisabledReason(branch.Name)
|
||||
if _, ok := self.c.Model().PullRequestsMap[branch.Name]; !ok {
|
||||
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *BranchesController) openPRInBrowser(branch *models.Branch) error {
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(branch.Name)
|
||||
pr, ok := self.c.Model().PullRequestsMap[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,6 +2,8 @@ package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
@@ -441,7 +443,11 @@ 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 {
|
||||
return repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
|
||||
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
|
||||
|
||||
@@ -2,7 +2,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
||||
@@ -131,14 +130,3 @@ 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,7 +3,6 @@ package controllers
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -75,14 +74,6 @@ 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,
|
||||
@@ -215,58 +206,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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,19 +154,11 @@ 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 {
|
||||
if relPath := repoRelativePath(worktreePath, path); relPath != "" {
|
||||
relPaths = append(relPaths, relPath)
|
||||
relPath, err := filepath.Rel(worktreePath, path)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
continue
|
||||
}
|
||||
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,26 +78,16 @@ 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 func(path string) string { return path }
|
||||
return infos
|
||||
}
|
||||
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
return func(path string) string {
|
||||
return repoPathOfTreePath(path, treesDir, worktreePath)
|
||||
}
|
||||
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
|
||||
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
|
||||
@@ -345,98 +335,62 @@ func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, f
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
|
||||
// empty for a row whose identity couldn't be recovered.
|
||||
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
toRepoTerms := self.repoTermsMapper(view)
|
||||
paths := make([]string, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok {
|
||||
paths[i] = toRepoTerms(row.info.Path)
|
||||
paths[i] = row.info.Path
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// 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.
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), the first located row of the file adjacent to `from`
|
||||
// in the given direction — the row file navigation lands on. It is the pure index
|
||||
// arithmetic behind AdjacentFile.
|
||||
//
|
||||
// A file is identified by its path, 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.
|
||||
// A file is identified by its path, so we look for where the path changes, skipping
|
||||
// rows that carry none: those are the blank separator rows between files, or the
|
||||
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
||||
// So the landing row is the file's header wherever the source says so — a parseable
|
||||
// buffer, or a renderer that tags its headers — and the file's first content line
|
||||
// otherwise, which is an accepted degradation.
|
||||
func fileStart(paths []string, from int, forward bool) (int, bool) {
|
||||
anchorPath, ok := anchorFilePath(paths, from)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
starts := fileStarts(paths)
|
||||
_, anchor, ok := lo.FindIndexOf(starts, func(start diffFileStart) bool {
|
||||
return start.path == anchorPath
|
||||
})
|
||||
if !ok {
|
||||
if forward {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" && paths[i] != anchorPath {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target := anchor + lo.Ternary(forward, 1, -1)
|
||||
if target < 0 || target >= len(starts) {
|
||||
// Walk back past the current file (its rows and any unlocated ones) to the previous
|
||||
// file's last located row, then back over that whole file, landing on its first.
|
||||
i := from
|
||||
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return starts[target].row, true
|
||||
prevPath := paths[i]
|
||||
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
|
||||
i--
|
||||
}
|
||||
for paths[i] != prevPath {
|
||||
i++
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
|
||||
@@ -48,41 +48,6 @@ 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,21 +33,6 @@ 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, 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.
|
||||
// Putting a selection in the focused main view: where it starts out, and how it is
|
||||
// widened to a whole change block. Both are answered from what the view is showing,
|
||||
// as recovered by the queries next door.
|
||||
|
||||
// EstablishSelection turns on the focused main view's selection once the view has
|
||||
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
|
||||
@@ -63,66 +63,6 @@ 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
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
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,12 +2,9 @@ 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 wraps our hosting_service package, and answers what we know about the
|
||||
// repo's pull requests on it
|
||||
// this helper just wraps our hosting_service package
|
||||
|
||||
type HostHelper struct {
|
||||
c *HelperCommon
|
||||
@@ -21,24 +18,6 @@ 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,11 +667,15 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
|
||||
return self.c.Helpers().Host.NoPullRequestDisabledReason(self.c.Model().CheckedOutBranch)
|
||||
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
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) openPRInBrowser() error {
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.c.Model().CheckedOutBranch)
|
||||
pr, ok := self.c.Model().PullRequestsMap[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,13 +1,6 @@
|
||||
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"
|
||||
@@ -22,13 +15,10 @@ type MainViewController struct {
|
||||
context *context.MainContext
|
||||
otherContext *context.MainContext
|
||||
|
||||
dragAutoscroller *helpers.DragAutoscroller
|
||||
draggingWithMouse bool
|
||||
lineFlashGeneration uint64
|
||||
dragAutoscroller *helpers.DragAutoscroller
|
||||
draggingWithMouse bool
|
||||
}
|
||||
|
||||
const editedLineFlashDuration = 200 * time.Millisecond
|
||||
|
||||
var _ types.IController = &MainViewController{}
|
||||
|
||||
func NewMainViewController(
|
||||
@@ -159,23 +149,6 @@ 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,
|
||||
@@ -268,20 +241,6 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
|
||||
Key: gocui.MouseRelease,
|
||||
Handler: self.onDragRelease,
|
||||
},
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
Modifier: gocui.ModAlt,
|
||||
Handler: self.editClickedLine,
|
||||
HandleWhenPopupPanelFocused: true,
|
||||
},
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
Modifier: gocui.ModShift,
|
||||
Handler: self.editClickedLine,
|
||||
HandleWhenPopupPanelFocused: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,22 +300,17 @@ 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.
|
||||
// stack or has no diff panel beneath it. The IsInStack guard is essential:
|
||||
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
|
||||
// leads here) also runs for off-stack panes — at startup and while generating the
|
||||
// cheatsheets, where the stack is empty.
|
||||
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
|
||||
if diffContext, ok := self.sidePanelBeneath().(types.DiffMainViewContext); ok {
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return types.DiffMainViewTypeNone
|
||||
}
|
||||
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
|
||||
return diffContext.GetDiffMainViewType()
|
||||
}
|
||||
return types.DiffMainViewTypeNone
|
||||
@@ -366,7 +320,10 @@ 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 {
|
||||
sidePanel := self.sidePanelBeneath()
|
||||
if !self.c.Context().IsInStack(self.context) {
|
||||
return nil
|
||||
}
|
||||
sidePanel := self.c.Context().NextInStack(self.context)
|
||||
if sidePanel == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -462,19 +419,6 @@ 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.pullRequestBranch() == "" {
|
||||
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
|
||||
@@ -569,90 +513,11 @@ 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,
|
||||
// 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}
|
||||
}
|
||||
if reason := self.c.Helpers().Host.NoPullRequestDisabledReason(self.pullRequestBranch()); reason != nil {
|
||||
return reason
|
||||
}
|
||||
return self.commitsOutsidePullRequestDisabledReason()
|
||||
}
|
||||
|
||||
// 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() *types.DisabledReason {
|
||||
commits, _ := self.pullRequestCommits()
|
||||
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
|
||||
}
|
||||
|
||||
func (self *MainViewController) editClickedLine(opts gocui.ViewMouseBindingOpts) error {
|
||||
var flashGeneration uint64
|
||||
err := self.editDiffLine(opts.Y, func() {
|
||||
self.lineFlashGeneration++
|
||||
flashGeneration = self.lineFlashGeneration
|
||||
self.context.GetView().SetLineFlash(self.lineToFlash(opts.Y))
|
||||
self.c.GocuiGui().ForceFlushViewsContentOnly(self.c.GocuiGui().Views())
|
||||
})
|
||||
if flashGeneration != 0 {
|
||||
time.AfterFunc(editedLineFlashDuration, func() {
|
||||
self.c.OnUIThreadContentOnlyBackground(func() error {
|
||||
if self.lineFlashGeneration == flashGeneration {
|
||||
self.context.GetView().ClearLineFlash()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// lineToFlash returns the view line to flash for an edit of the line clicked at the
|
||||
// given one. The editor is sent to where that line begins, so a long line wrapped over
|
||||
// several view lines is flashed at the first of them. If the editor wraps the line too,
|
||||
// its cursor ends up on the flashed line. When the line begins above the top of the
|
||||
// viewport, the clicked line is flashed, as the only part of the line on screen.
|
||||
func (self *MainViewController) lineToFlash(clickedViewLine int) int {
|
||||
view := self.context.GetView()
|
||||
bufferLine, ok := view.BufferLineForViewLine(clickedViewLine)
|
||||
if !ok {
|
||||
return clickedViewLine
|
||||
}
|
||||
firstViewLine, ok := view.ViewLineForBufferLine(bufferLine)
|
||||
if !ok || firstViewLine < view.OriginY() {
|
||||
return clickedViewLine
|
||||
}
|
||||
return firstViewLine
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
||||
// Carry the select mode over from the pane we're leaving, so that clicking into
|
||||
// the other pane keeps hunk mode even the first time we enter it — its own mode
|
||||
@@ -847,12 +712,50 @@ 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) {
|
||||
self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop)
|
||||
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)
|
||||
}
|
||||
|
||||
func (self *MainViewController) openJumpToFileMenu() error {
|
||||
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(self.context, self.c.Tr.JumpToFile)
|
||||
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
|
||||
// the view where it is when that row is on screen already. The last screenful of the
|
||||
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
|
||||
// showing.
|
||||
func (self *MainViewController) scrollTargetToTop(target int) {
|
||||
view := self.context.GetView()
|
||||
originY, height := self.context.GetViewTrait().ViewPortYBounds()
|
||||
if target >= originY && target < originY+height {
|
||||
return
|
||||
}
|
||||
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
|
||||
}
|
||||
|
||||
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
|
||||
@@ -884,7 +787,17 @@ func (self *MainViewController) collapseForLineMove() {
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
return
|
||||
}
|
||||
self.c.Helpers().DiffLine.CollapseNonStickyRange(self.context)
|
||||
self.collapseNonStickyRange()
|
||||
}
|
||||
|
||||
// collapseNonStickyRange drops a range that only grows while shift is held back to a
|
||||
// single line at the cursor.
|
||||
func (self *MainViewController) collapseNonStickyRange() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
self.context.GetView().CancelRangeSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// adjustSelection moves the selection by delta view lines, for the plain up/down and
|
||||
@@ -1053,17 +966,11 @@ func (self *MainViewController) editLine() error {
|
||||
if !view.Highlight {
|
||||
return nil
|
||||
}
|
||||
return self.editDiffLine(view.SelectedLineIdx(), nil)
|
||||
}
|
||||
|
||||
func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) error {
|
||||
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(self.context.GetView(), viewLine)
|
||||
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if beforeEdit != nil {
|
||||
beforeEdit()
|
||||
}
|
||||
|
||||
// A file-header row points at the file as a whole rather than at a line in it, so
|
||||
// it opens the file without jumping anywhere — as pressing edit on a file in a side
|
||||
@@ -1078,104 +985,6 @@ 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 {
|
||||
pr, ok := self.c.Helpers().Host.PullRequestForBranch(self.pullRequestBranch())
|
||||
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)
|
||||
}
|
||||
|
||||
commits, baseHash := self.pullRequestCommits()
|
||||
if len(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(commits, baseHash), relativePath, info)
|
||||
return self.c.OS().OpenLink(url)
|
||||
}
|
||||
|
||||
// pullRequestBranch returns the branch whose pull request would show the diff in this
|
||||
// pane, as the panel beneath names it, and "" where no pull request shows it.
|
||||
func (self *MainViewController) pullRequestBranch() string {
|
||||
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return prContext.BranchForPullRequest()
|
||||
}
|
||||
|
||||
// pullRequestCommits returns the commits whose diff the pane is showing, and the commit
|
||||
// that diff starts after, as the panel beneath names them. The diff's line numbers are
|
||||
// the ones the pull request's page for those commits shows.
|
||||
func (self *MainViewController) pullRequestCommits() ([]*models.Commit, string) {
|
||||
prContext, ok := self.sidePanelBeneath().(types.PullRequestDiffContext)
|
||||
if !ok {
|
||||
return nil, ""
|
||||
}
|
||||
return prContext.CommitsForPullRequest()
|
||||
}
|
||||
|
||||
// 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() {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
@@ -261,11 +262,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 := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if relativePath == "" {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return self.context().FileTreeViewModel.GetFile(relativePath)
|
||||
return self.context().FileTreeViewModel.GetFile(filepath.ToSlash(relativePath))
|
||||
}
|
||||
|
||||
// applyDiffLines applies the given change lines of one file — a line, a hunk, a range —
|
||||
|
||||
+11
-28
@@ -82,9 +82,6 @@ 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.
|
||||
@@ -418,17 +415,6 @@ 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)
|
||||
}
|
||||
@@ -789,18 +775,17 @@ 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{},
|
||||
diffStatLinkWriterMap: map[string]*helpers.DiffStatLinkWriter{},
|
||||
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{},
|
||||
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:
|
||||
@@ -953,8 +938,6 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
|
||||
|
||||
g.ErrorHandler = gui.PopupHandler.ErrorHandler
|
||||
|
||||
terminalName, terminalVersion := g.Terminal()
|
||||
gui.c.Log.Infof("Terminal: %s %s", terminalName, terminalVersion)
|
||||
gui.c.Log.Infof("Terminal color scheme: %s", g.DetectedColorScheme())
|
||||
g.SetColorSchemeChangeHandler(func(colorScheme gocui.DetectedColorScheme) error {
|
||||
gui.c.Log.Infof("Terminal color scheme changed: %s", colorScheme)
|
||||
|
||||
+11
-19
@@ -51,40 +51,34 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) {
|
||||
}
|
||||
|
||||
func (self *GuiDriver) Click(x, y int) {
|
||||
self.ClickWithModifier(x, y, gocui.ModNone)
|
||||
}
|
||||
|
||||
// ClickWithModifier clicks with a keyboard modifier held down for the whole
|
||||
// gesture, as a terminal reports it.
|
||||
func (self *GuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
|
||||
self.CheckAllToastsAcknowledged()
|
||||
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary, modifier)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonNone, modifier)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonNone)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) ClickAndHold(x, y int) {
|
||||
self.CheckAllToastsAcknowledged()
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
||||
}
|
||||
|
||||
// MouseMove reports the mouse at a new position with the left button still
|
||||
// held down, i.e. a drag movement. (No test needs pointer motion without a
|
||||
// button held, so that variant doesn't exist.)
|
||||
func (self *GuiDriver) MouseMove(x, y int) {
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary, gocui.ModNone)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) ScrollWheelDown(x, y int) {
|
||||
self.replayMouseEvent(x, y, tcell.WheelDown, gocui.ModNone)
|
||||
self.replayMouseEvent(x, y, tcell.WheelDown)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) MouseRelease(x, y int) {
|
||||
self.replayMouseEvent(x, y, tcell.ButtonNone, gocui.ModNone)
|
||||
self.replayMouseEvent(x, y, tcell.ButtonNone)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) {
|
||||
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone, gocui.ModNone)
|
||||
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) WaitUntilIdle() {
|
||||
@@ -95,16 +89,14 @@ func (self *GuiDriver) OnUIThreadAndWait(f func()) {
|
||||
_ = self.gui.g.OnUIThreadAndWait(f)
|
||||
}
|
||||
|
||||
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier) {
|
||||
self.replayMouseEventWithoutWaiting(x, y, buttons, modifier)
|
||||
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
|
||||
self.replayMouseEventWithoutWaiting(x, y, buttons)
|
||||
self.waitTillIdle()
|
||||
}
|
||||
|
||||
func (self *GuiDriver) replayMouseEventWithoutWaiting(
|
||||
x, y int, buttons tcell.ButtonMask, modifier gocui.Modifier,
|
||||
) {
|
||||
func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) {
|
||||
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
|
||||
tcell.NewEventMouse(x, y, buttons, tcell.ModMask(modifier)),
|
||||
tcell.NewEventMouse(x, y, buttons, 0),
|
||||
0,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -74,12 +74,6 @@ 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 {
|
||||
|
||||
@@ -59,7 +59,6 @@ func GetCommitListDisplayStrings(
|
||||
startIdx int,
|
||||
endIdx int,
|
||||
showGraph bool,
|
||||
graphSymbolSet graph.SymbolSet,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
) [][]string {
|
||||
mutex.Lock()
|
||||
@@ -108,7 +107,6 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -126,7 +124,6 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
allGraphLines = append(allGraphLines, graphLines...)
|
||||
}
|
||||
@@ -148,7 +145,6 @@ func GetCommitListDisplayStrings(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
selectedCommitHashPtr,
|
||||
graphSymbolSet,
|
||||
)
|
||||
getGraphLine = func(idx int) string {
|
||||
if idx >= graphOffset {
|
||||
|
||||
@@ -611,7 +611,6 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
s.startIdx,
|
||||
s.endIdx,
|
||||
s.showGraph,
|
||||
graph.BoxDrawingSymbols,
|
||||
s.bisectInfo,
|
||||
)
|
||||
|
||||
@@ -636,7 +635,7 @@ func TestGraphColorsFollowTheAuthorColors(t *testing.T) {
|
||||
models.NewCommit(hashPool, models.NewCommitOpts{Hash: "authorcolors2", AuthorName: "Jane Doe"}),
|
||||
}
|
||||
renderGraph := func() string {
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil, graph.BoxDrawingSymbols), "\n")
|
||||
return strings.Join(graph.RenderAux(loadPipesets(commits), commits, nil), "\n")
|
||||
}
|
||||
|
||||
authors.SetCustomAuthors(map[string]string{"Jane Doe": "red"})
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The branch drawing symbols are characters in the Unicode Private Use Area
|
||||
// (U+F5D0 to U+F60D) for drawing git graphs, introduced by kitty in
|
||||
// https://github.com/kovidgoyal/kitty/pull/7681. Unlike the box drawing
|
||||
// characters, they can show how the lines in a cell connect. For example, there
|
||||
// is a symbol for a cell in which a line from above bends to the left and a
|
||||
// line from the left bends down.
|
||||
|
||||
const branchDrawingHorizontal = "\uf5d0"
|
||||
|
||||
// The lines in a cell that isn't a commit
|
||||
type lineCourses struct {
|
||||
up, down verticalLine
|
||||
horizontal bool
|
||||
}
|
||||
|
||||
var branchDrawingConnections = map[lineCourses]string{
|
||||
{}: " ",
|
||||
{horizontal: true}: branchDrawingHorizontal,
|
||||
// A horizontal line passing behind a vertical one only shows in the
|
||||
// neighbouring cells
|
||||
{up: straightLine, down: straightLine}: "\uf5d1", // │
|
||||
{up: straightLine, down: straightLine, horizontal: true}: "\uf5d1", // │
|
||||
{up: lineToLeft}: "\uf5d9", // ╯
|
||||
{up: lineToRight}: "\uf5d8", // ╰
|
||||
{down: lineToLeft}: "\uf5d7", // ╮
|
||||
{down: lineToRight}: "\uf5d6", // ╭
|
||||
{up: lineToLeft, horizontal: true}: "\uf5e3", // ╯ on ─
|
||||
{up: lineToRight, horizontal: true}: "\uf5e4", // ╰ on ─
|
||||
{down: lineToLeft, horizontal: true}: "\uf5e0", // ╮ on ─
|
||||
{down: lineToRight, horizontal: true}: "\uf5e1", // ╭ on ─
|
||||
{up: lineToLeft, down: lineToLeft}: "\uf5df", // ╯ and ╮
|
||||
{up: lineToRight, down: lineToRight}: "\uf5dc", // ╰ and ╭
|
||||
{up: lineToLeft, down: lineToLeft, horizontal: true}: "\uf5e8", // ╯ and ╮ on ─
|
||||
{up: lineToRight, down: lineToRight, horizontal: true}: "\uf5e9", // ╰ and ╭ on ─
|
||||
{up: lineToLeft, down: lineToRight, horizontal: true}: "\uf5ec", // ╯ and ╭ on ─
|
||||
{up: lineToRight, down: lineToLeft, horizontal: true}: "\uf5ed", // ╰ and ╮ on ─
|
||||
}
|
||||
|
||||
// The edges of a commit's cell that its lines touch
|
||||
type commitEdges struct {
|
||||
up, down, left, right bool
|
||||
}
|
||||
|
||||
// Commits are drawn as hollow circles, merge commits as filled ones. Both
|
||||
// connect to the lines at the given edges.
|
||||
var branchDrawingCommitSymbols = map[commitEdges]struct{ commit, merge string }{
|
||||
{}: {"\uf5ef", "\uf5ee"},
|
||||
{right: true}: {"\uf5f1", "\uf5f0"},
|
||||
{left: true}: {"\uf5f3", "\uf5f2"},
|
||||
{left: true, right: true}: {"\uf5f5", "\uf5f4"},
|
||||
{down: true}: {"\uf5f7", "\uf5f6"},
|
||||
{up: true}: {"\uf5f9", "\uf5f8"},
|
||||
{up: true, down: true}: {"\uf5fb", "\uf5fa"},
|
||||
{down: true, right: true}: {"\uf5fd", "\uf5fc"},
|
||||
{down: true, left: true}: {"\uf5ff", "\uf5fe"},
|
||||
{up: true, right: true}: {"\uf601", "\uf600"},
|
||||
{up: true, left: true}: {"\uf603", "\uf602"},
|
||||
{up: true, down: true, right: true}: {"\uf605", "\uf604"},
|
||||
{up: true, down: true, left: true}: {"\uf607", "\uf606"},
|
||||
{down: true, left: true, right: true}: {"\uf609", "\uf608"},
|
||||
{up: true, left: true, right: true}: {"\uf60b", "\uf60a"},
|
||||
{up: true, down: true, left: true, right: true}: {"\uf60d", "\uf60c"},
|
||||
}
|
||||
|
||||
func (cell *Cell) branchDrawingChars() (string, string) {
|
||||
second := " "
|
||||
if cell.right {
|
||||
second = branchDrawingHorizontal
|
||||
}
|
||||
|
||||
switch cell.cellType {
|
||||
case COMMIT, MERGE:
|
||||
symbols := branchDrawingCommitSymbols[commitEdges{
|
||||
up: cell.up != noLine,
|
||||
down: cell.down != noLine,
|
||||
left: cell.left,
|
||||
right: cell.right,
|
||||
}]
|
||||
if cell.cellType == MERGE {
|
||||
return symbols.merge, second
|
||||
}
|
||||
return symbols.commit, second
|
||||
default:
|
||||
if cell.horizontalOnTop {
|
||||
return branchDrawingHorizontal, second
|
||||
}
|
||||
if first, ok := branchDrawingConnections[lineCourses{cell.up, cell.down, cell.horizontal}]; ok {
|
||||
return first, second
|
||||
}
|
||||
// There is no symbol for lines that the layout of the graph never
|
||||
// produces, such as a lone half of a line
|
||||
first, _ := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
|
||||
// The terminals that draw the branch drawing symbols themselves, with the
|
||||
// first version that draws all of them
|
||||
var terminalsWithBranchDrawingSymbols = map[string][]int{
|
||||
"kitty": {0, 36, 2},
|
||||
"ghostty": {1, 0, 0},
|
||||
}
|
||||
|
||||
// TerminalDrawsBranchDrawingSymbols says whether a terminal is known to draw
|
||||
// the branch drawing symbols itself, given the name and version it reports
|
||||
func TerminalDrawsBranchDrawingSymbols(name, version string) bool {
|
||||
minVersion, ok := terminalsWithBranchDrawingSymbols[strings.ToLower(name)]
|
||||
return ok && slices.Compare(versionNumbers(version), minVersion) >= 0
|
||||
}
|
||||
|
||||
// The numbers at the start of the dot-separated parts of a version, e.g.
|
||||
// [1 3 0] for "1.3.0-dev+abc"
|
||||
func versionNumbers(version string) []int {
|
||||
var numbers []int
|
||||
for part := range strings.SplitSeq(version, ".") {
|
||||
digits := part[:len(part)-len(strings.TrimLeft(part, "0123456789"))]
|
||||
number, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
numbers = append(numbers, number)
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTerminalDrawsBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
expected bool
|
||||
}{
|
||||
{name: "kitty", version: "0.36.1", expected: false},
|
||||
{name: "kitty", version: "0.36.2", expected: true},
|
||||
{name: "kitty", version: "0.44.0", expected: true},
|
||||
{name: "ghostty", version: "0.9.0", expected: false},
|
||||
{name: "ghostty", version: "1.0.0", expected: true},
|
||||
{name: "ghostty", version: "1.3.0-main+0123abcd", expected: true},
|
||||
{name: "WezTerm", version: "20250601-102030-89abcdef", expected: false},
|
||||
{name: "tmux", version: "3.5a", expected: false},
|
||||
{name: "iTerm2", version: "3.6.4", expected: false},
|
||||
{name: "kitty", version: "", expected: false},
|
||||
{name: "", version: "", expected: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name+" "+test.version, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, TerminalDrawsBranchDrawingSymbols(test.name, test.version))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,6 @@ const (
|
||||
CommitSymbol = '○'
|
||||
)
|
||||
|
||||
// The characters that the commit graph is drawn with
|
||||
type SymbolSet uint8
|
||||
|
||||
const (
|
||||
BoxDrawingSymbols SymbolSet = iota
|
||||
BranchDrawingSymbols
|
||||
)
|
||||
|
||||
type cellType int
|
||||
|
||||
const (
|
||||
@@ -29,40 +21,25 @@ const (
|
||||
MERGE
|
||||
)
|
||||
|
||||
// How a line that touches the top or bottom edge of a cell runs within it
|
||||
type verticalLine uint8
|
||||
|
||||
const (
|
||||
noLine verticalLine = iota
|
||||
// On to the opposite edge, or into the commit symbol
|
||||
straightLine
|
||||
// Bends towards the left edge
|
||||
lineToLeft
|
||||
// Bends towards the right edge
|
||||
lineToRight
|
||||
)
|
||||
|
||||
type Cell struct {
|
||||
// The lines that touch the top and bottom edges
|
||||
up, down verticalLine
|
||||
// Whether lines touch the left and right edges
|
||||
left, right bool
|
||||
// Whether a line passes through from the left edge to the right edge
|
||||
horizontal bool
|
||||
// Whether that line is drawn over the vertical line that it crosses
|
||||
horizontalOnTop bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
up, down, left, right bool
|
||||
cellType cellType
|
||||
rightStyle *style.TextStyle
|
||||
style *style.TextStyle
|
||||
}
|
||||
|
||||
func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
|
||||
var first, second string
|
||||
switch symbolSet {
|
||||
case BoxDrawingSymbols:
|
||||
first, second = cell.boxDrawingChars()
|
||||
case BranchDrawingSymbols:
|
||||
first, second = cell.branchDrawingChars()
|
||||
func (cell *Cell) render(writer io.StringWriter) {
|
||||
up, down, left, right := cell.up, cell.down, cell.left, cell.right
|
||||
|
||||
first, second := getBoxDrawingChars(up, down, left, right)
|
||||
var adjustedFirst string
|
||||
switch cell.cellType {
|
||||
case CONNECTION:
|
||||
adjustedFirst = first
|
||||
case COMMIT:
|
||||
adjustedFirst = string(CommitSymbol)
|
||||
case MERGE:
|
||||
adjustedFirst = string(MergeSymbol)
|
||||
}
|
||||
|
||||
var rightStyle *style.TextStyle
|
||||
@@ -82,22 +59,10 @@ func (cell *Cell) render(writer io.StringWriter, symbolSet SymbolSet) {
|
||||
styledSecondChar = cachedSprint(*rightStyle, second)
|
||||
}
|
||||
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, first))
|
||||
_, _ = writer.WriteString(cachedSprint(*cell.style, adjustedFirst))
|
||||
_, _ = writer.WriteString(styledSecondChar)
|
||||
}
|
||||
|
||||
func (cell *Cell) boxDrawingChars() (string, string) {
|
||||
first, second := getBoxDrawingChars(cell.up != noLine, cell.down != noLine, cell.left, cell.right)
|
||||
switch cell.cellType {
|
||||
case COMMIT:
|
||||
return string(CommitSymbol), second
|
||||
case MERGE:
|
||||
return string(MergeSymbol), second
|
||||
default:
|
||||
return first, second
|
||||
}
|
||||
}
|
||||
|
||||
type rgbCacheKey struct {
|
||||
*color.RGBStyle
|
||||
str string
|
||||
@@ -133,35 +98,34 @@ func cachedSprint(style style.TextStyle, str string) string {
|
||||
}
|
||||
|
||||
func (cell *Cell) reset() {
|
||||
cell.up = noLine
|
||||
cell.down = noLine
|
||||
cell.up = false
|
||||
cell.down = false
|
||||
cell.left = false
|
||||
cell.right = false
|
||||
cell.horizontal = false
|
||||
cell.horizontalOnTop = false
|
||||
}
|
||||
|
||||
func (cell *Cell) setUp(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.up = line
|
||||
func (cell *Cell) setUp(style *style.TextStyle) *Cell {
|
||||
cell.up = true
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setDown(style *style.TextStyle, line verticalLine) *Cell {
|
||||
cell.down = line
|
||||
func (cell *Cell) setDown(style *style.TextStyle) *Cell {
|
||||
cell.down = true
|
||||
cell.style = style
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setLeft(style *style.TextStyle) *Cell {
|
||||
cell.left = true
|
||||
if cell.up == noLine && cell.down == noLine {
|
||||
if !cell.up && !cell.down {
|
||||
// vertical trumps left
|
||||
cell.style = style
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
//nolint:unparam
|
||||
func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
cell.right = true
|
||||
if cell.rightStyle == nil || override {
|
||||
@@ -170,11 +134,6 @@ func (cell *Cell) setRight(style *style.TextStyle, override bool) *Cell {
|
||||
return cell
|
||||
}
|
||||
|
||||
func (cell *Cell) setHorizontal(style *style.TextStyle, overrideRightStyle bool) *Cell {
|
||||
cell.horizontal = true
|
||||
return cell.setLeft(style).setRight(style, overrideRightStyle)
|
||||
}
|
||||
|
||||
func (cell *Cell) setStyle(style *style.TextStyle) *Cell {
|
||||
cell.style = style
|
||||
return cell
|
||||
|
||||
@@ -45,13 +45,13 @@ func (self Pipe) right() int16 {
|
||||
return max(self.fromPos, self.toPos)
|
||||
}
|
||||
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle, symbolSet SymbolSet) []string {
|
||||
func RenderCommitGraph(commits []*models.Commit, selectedCommitHashPtr *string, getStyle func(c *models.Commit) *style.TextStyle) []string {
|
||||
pipeSets := GetPipeSets(commits, getStyle)
|
||||
if len(pipeSets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr, symbolSet)
|
||||
lines := RenderAux(pipeSets, commits, selectedCommitHashPtr)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func GetPipeSets(commits []*models.Commit, getStyle func(c *models.Commit) *styl
|
||||
})
|
||||
}
|
||||
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string, symbolSet SymbolSet) []string {
|
||||
func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPtr *string) []string {
|
||||
maxProcs := runtime.GOMAXPROCS(0)
|
||||
|
||||
// splitting up the rendering of the graph into multiple goroutines allows us to render the graph in parallel
|
||||
@@ -93,7 +93,7 @@ func RenderAux(pipeSets [][]Pipe, commits []*models.Commit, selectedCommitHashPt
|
||||
if k > 0 {
|
||||
prevCommit = commits[k-1]
|
||||
}
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit, symbolSet)
|
||||
line := renderPipeSet(pipeSet, selectedCommitHashPtr, prevCommit)
|
||||
innerLines = append(innerLines, line)
|
||||
}
|
||||
chunks[i] = innerLines
|
||||
@@ -276,7 +276,6 @@ func renderPipeSet(
|
||||
pipes []Pipe,
|
||||
selectedCommitHashPtr *string,
|
||||
prevCommit *models.Commit,
|
||||
symbolSet SymbolSet,
|
||||
) string {
|
||||
maxPos := int16(0)
|
||||
commitPos := int16(0)
|
||||
@@ -305,17 +304,17 @@ func renderPipeSet(
|
||||
|
||||
if left != right {
|
||||
for i := left + 1; i < right; i++ {
|
||||
cells[i].setHorizontal(style, overrideRightStyle)
|
||||
cells[i].setLeft(style).setRight(style, overrideRightStyle)
|
||||
}
|
||||
cells[left].setRight(style, overrideRightStyle)
|
||||
cells[right].setLeft(style)
|
||||
}
|
||||
|
||||
if pipe.kind == STARTS || pipe.kind == CONTINUES {
|
||||
cells[pipe.toPos].setDown(style, lineTowards(pipe.toPos, pipe.fromPos))
|
||||
cells[pipe.toPos].setDown(style)
|
||||
}
|
||||
if pipe.kind == TERMINATES || pipe.kind == CONTINUES {
|
||||
cells[pipe.fromPos].setUp(style, lineTowards(pipe.fromPos, pipe.toPos))
|
||||
cells[pipe.fromPos].setUp(style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,31 +343,14 @@ func renderPipeSet(
|
||||
}
|
||||
|
||||
for _, pipe := range nonSelectedPipes {
|
||||
if pipe.kind == STARTS {
|
||||
continue
|
||||
if pipe.kind != STARTS && !(pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos) {
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
}
|
||||
if pipe.kind == TERMINATES && pipe.fromPos == commitPos && pipe.toPos == commitPos {
|
||||
// Record this line without drawing it, so that the commit symbol
|
||||
// keeps the style of the commit itself. The branch drawing commit
|
||||
// symbols connect to it. The pipe from above the first commit
|
||||
// doesn't come from a commit, so it has no line.
|
||||
if !equalHashes(pipe.fromHash, &StartCommitHash) {
|
||||
cells[commitPos].up = straightLine
|
||||
}
|
||||
continue
|
||||
}
|
||||
renderPipe(&pipe, pipe.style, false)
|
||||
}
|
||||
|
||||
// Box drawing characters can't show every combination of lines in a cell,
|
||||
// so the cells that the selected commit's lines run through show only
|
||||
// those lines. The branch drawing symbols can, so they keep the other
|
||||
// lines too.
|
||||
if symbolSet == BoxDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
}
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left(); i <= pipe.right(); i++ {
|
||||
cells[i].reset()
|
||||
}
|
||||
}
|
||||
for _, pipe := range selectedPipes {
|
||||
@@ -378,22 +360,6 @@ func renderPipeSet(
|
||||
}
|
||||
}
|
||||
|
||||
// Where a line of the selected commit crosses the vertical line of another
|
||||
// commit, draw it over that line, so that it reads as one line. The
|
||||
// vertical line visibly continues above and below. A vertical line in a
|
||||
// cell that doesn't have the highlight style belongs to another commit.
|
||||
if symbolSet == BranchDrawingSymbols {
|
||||
for _, pipe := range selectedPipes {
|
||||
for i := pipe.left() + 1; i < pipe.right(); i++ {
|
||||
cell := cells[i]
|
||||
if cell.up == straightLine && cell.down == straightLine && cell.style != &highlightStyle {
|
||||
cell.horizontalOnTop = true
|
||||
cell.style = &highlightStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cType := COMMIT
|
||||
if isMerge {
|
||||
cType = MERGE
|
||||
@@ -405,24 +371,11 @@ func renderPipeSet(
|
||||
writer := &strings.Builder{}
|
||||
writer.Grow(len(cells) * 2)
|
||||
for _, cell := range cells {
|
||||
cell.render(writer, symbolSet)
|
||||
cell.render(writer)
|
||||
}
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
// The course of a line through the top or bottom edge of the cell at pos,
|
||||
// given the column of the pipe's other end
|
||||
func lineTowards(pos, otherPos int16) verticalLine {
|
||||
switch {
|
||||
case otherPos < pos:
|
||||
return lineToLeft
|
||||
case otherPos > pos:
|
||||
return lineToRight
|
||||
default:
|
||||
return straightLine
|
||||
}
|
||||
}
|
||||
|
||||
func equalHashes(a, b *string) bool {
|
||||
// if our selectedCommitHashPtr is nil, there is no selected commit
|
||||
if a == nil || b == nil {
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle, BoxDrawingSymbols)
|
||||
lines := RenderCommitGraph(commits, hashPool.Add("blah"), getStyle)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
@@ -247,325 +247,6 @@ func TestRenderCommitGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Box drawing look-alikes for the branch drawing symbols, so that the expected
|
||||
// output in tests is readable. The box drawing characters of the graph never
|
||||
// use ┤ and ┼; here they stand for the symbols that have two bends. Commit
|
||||
// symbols are shown as ○ and ◎, whichever lines they connect to.
|
||||
var branchDrawingLookAlikes = func() map[rune]rune {
|
||||
lookAlikes := map[rune]rune{
|
||||
'\uf5d0': '─',
|
||||
'\uf5d1': '│',
|
||||
'\uf5d6': '╭',
|
||||
'\uf5d7': '╮',
|
||||
'\uf5d8': '╰',
|
||||
'\uf5d9': '╯',
|
||||
'\uf5e0': '┬', // ╮ on ─
|
||||
'\uf5e3': '┴', // ╯ on ─
|
||||
'\uf5df': '┤', // ╯ and ╮
|
||||
'\uf5e8': '┼', // ╯ and ╮ on ─
|
||||
}
|
||||
for _, symbols := range branchDrawingCommitSymbols {
|
||||
lookAlikes[[]rune(symbols.commit)[0]] = CommitSymbol
|
||||
lookAlikes[[]rune(symbols.merge)[0]] = MergeSymbol
|
||||
}
|
||||
return lookAlikes
|
||||
}()
|
||||
|
||||
func TestRenderCommitGraphWithBranchDrawingSymbols(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commitOpts []models.NewCommitOpts
|
||||
selectedHash string
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "branch forked off a merge commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "5",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─│─╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with the merge commit above it selected",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"5"}},
|
||||
{Hash: "2", Parents: []string{"5", "4"}},
|
||||
{Hash: "4", Parents: []string{"5"}},
|
||||
{Hash: "5", Parents: []string{"6", "7"}},
|
||||
{Hash: "7", Parents: []string{"6"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
selectedHash: "2",
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎───╮
|
||||
4 │ │ ○
|
||||
5 ◎─┼─╯
|
||||
7 │ ○
|
||||
6 ○─╯`,
|
||||
},
|
||||
{
|
||||
name: "branch forked off a merge commit, with no line passing through",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2", "3"}},
|
||||
{Hash: "3", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"4", "5"}},
|
||||
{Hash: "4", Parents: []string{"6", "7"}},
|
||||
{Hash: "6", Parents: []string{"8"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ◎─╮
|
||||
3 │ ○
|
||||
2 ◎─┤
|
||||
4 ◎─│─╮
|
||||
6 ○ │ │`,
|
||||
},
|
||||
{
|
||||
name: "several lines ending in a commit",
|
||||
commitOpts: []models.NewCommitOpts{
|
||||
{Hash: "1", Parents: []string{"2"}},
|
||||
{Hash: "2", Parents: []string{"3", "4"}},
|
||||
{Hash: "3", Parents: []string{"5", "4"}},
|
||||
{Hash: "5", Parents: []string{"7", "8"}},
|
||||
{Hash: "7", Parents: []string{"4", "A"}},
|
||||
{Hash: "4", Parents: []string{"B"}},
|
||||
{Hash: "B", Parents: []string{"C"}},
|
||||
},
|
||||
expectedOutput: `
|
||||
1 ○
|
||||
2 ◎─╮
|
||||
3 ◎─│─╮
|
||||
5 ◎─│─│─╮
|
||||
7 ◎─│─│─│─╮
|
||||
4 ○─┴─╯ │ │
|
||||
B ○ ╭───╯ │`,
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
commits := lo.Map(test.commitOpts,
|
||||
func(opts models.NewCommitOpts, _ int) *models.Commit { return models.NewCommit(hashPool, opts) })
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(test.selectedHash), getStyle, BranchDrawingSymbols)
|
||||
|
||||
trimmedExpectedOutput := ""
|
||||
for line := range strings.SplitSeq(strings.TrimPrefix(test.expectedOutput, "\n"), "\n") {
|
||||
trimmedExpectedOutput += strings.TrimSpace(line) + "\n"
|
||||
}
|
||||
|
||||
output := ""
|
||||
for i, line := range lines {
|
||||
lookAlikes := strings.Map(func(r rune) rune {
|
||||
return lo.ValueOr(branchDrawingLookAlikes, r, r)
|
||||
}, utils.Decolorise(line))
|
||||
output += strings.TrimSpace(test.commitOpts[i].Hash+" "+lookAlikes) + "\n"
|
||||
}
|
||||
|
||||
assert.Equal(t, trimmedExpectedOutput, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSetWithBranchDrawingSymbols(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
green := style.FgGreen
|
||||
yellow := style.FgYellow
|
||||
magenta := style.FgMagenta
|
||||
nothing := style.Nothing
|
||||
|
||||
hashPool := &utils.StringPool{}
|
||||
pool := func(s string) *string { return hashPool.Add(s) }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pipes []Pipe
|
||||
prevCommit *models.Commit
|
||||
expectedStr string
|
||||
expectedStyles []style.TextStyle
|
||||
}{
|
||||
{
|
||||
name: "commit with lines above and below",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "first commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: &StartCommitHash, toHash: pool("b"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("b"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
expectedStr: "\uf5f7", // ○ with a line down
|
||||
expectedStyles: []style.TextStyle{green},
|
||||
},
|
||||
{
|
||||
name: "selected commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a"), toHash: pool("selected"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("c"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a"}),
|
||||
expectedStr: "\uf5fb", // ○ with lines up and down
|
||||
expectedStyles: []style.TextStyle{highlightStyle},
|
||||
},
|
||||
{
|
||||
name: "commit whose previous commit is selected and is a merge commit",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("selected"), toHash: pool("b3"), kind: CONTINUES, style: &red},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "selected"}),
|
||||
expectedStr: "\uf5fb \uf5d1", // ○ with lines up and down, │
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, nothing, highlightStyle,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
green, green, magenta, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge commit that a branch forks off, with the branch's first commit selected",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("c1"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, cyan, cyan,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit crossing another line",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("selected"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("selected"), toHash: pool("a3"), kind: STARTS, style: &yellow},
|
||||
{fromPos: 1, toPos: 1, fromHash: pool("b1"), toHash: pool("b2"), kind: CONTINUES, style: &magenta},
|
||||
{fromPos: 3, toPos: 0, fromHash: pool("e1"), toHash: pool("selected"), kind: TERMINATES, style: &green},
|
||||
{fromPos: 0, toPos: 2, fromHash: pool("selected"), toHash: pool("c3"), kind: STARTS, style: &yellow},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5d0\uf5d0\uf5e0\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ─, ─, ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, highlightStyle, highlightStyle, highlightStyle, green, green,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "line of the selected commit passing through a cell in which other lines bend",
|
||||
pipes: []Pipe{
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a1"), toHash: pool("a2"), kind: TERMINATES, style: &red},
|
||||
{fromPos: 1, toPos: 0, fromHash: pool("b1"), toHash: pool("a2"), kind: TERMINATES, style: &magenta},
|
||||
{fromPos: 2, toPos: 0, fromHash: pool("selected"), toHash: pool("a2"), kind: TERMINATES, style: &cyan},
|
||||
{fromPos: 0, toPos: 0, fromHash: pool("a2"), toHash: pool("a3"), kind: STARTS, style: &green},
|
||||
{fromPos: 0, toPos: 1, fromHash: pool("a2"), toHash: pool("b3"), kind: STARTS, style: &green},
|
||||
},
|
||||
prevCommit: models.NewCommit(hashPool, models.NewCommitOpts{Hash: "a1"}),
|
||||
expectedStr: "\uf604\uf5d0\uf5e8\uf5d0\uf5d9", // ◎ with lines up, down and right, ─, ╯ and ╮ on ─, ─, ╯
|
||||
expectedStyles: []style.TextStyle{
|
||||
highlightStyle, highlightStyle, magenta, highlightStyle, highlightStyle,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BranchDrawingSymbols)
|
||||
if len([]rune(test.expectedStr)) != len(test.expectedStyles) {
|
||||
t.Fatalf("Error in test setup: you have %d characters in the expected output but have specified %d styles", len([]rune(test.expectedStr)), len(test.expectedStyles))
|
||||
}
|
||||
expectedStr := ""
|
||||
for i, char := range []rune(test.expectedStr) {
|
||||
expectedStr += test.expectedStyles[i].Sprint(string(char))
|
||||
}
|
||||
expectedStr += " "
|
||||
|
||||
assert.Equal(t, expectedStr, actualStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchDrawingSymbolsCoverAllCells(t *testing.T) {
|
||||
hashPool := &utils.StringPool{}
|
||||
commits := generateCommits(hashPool, 1000)
|
||||
getStyle := func(commit *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
isBoxDrawingChar := func(r rune) bool { return r >= '\u2500' && r <= '\u257f' }
|
||||
|
||||
for _, selectedHash := range []string{"none", commits[10].Hash(), commits[500].Hash()} {
|
||||
lines := RenderCommitGraph(commits, hashPool.Add(selectedHash), getStyle, BranchDrawingSymbols)
|
||||
for i, line := range lines {
|
||||
line = utils.Decolorise(line)
|
||||
assert.False(t, strings.ContainsFunc(line, isBoxDrawingChar),
|
||||
"row %d falls back to box drawing characters: %q", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPipeSet(t *testing.T) {
|
||||
cyan := style.FgCyan
|
||||
red := style.FgRed
|
||||
@@ -781,7 +462,7 @@ func TestRenderPipeSet(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit, BoxDrawingSymbols)
|
||||
actualStr := renderPipeSet(test.pipes, pool("selected"), test.prevCommit)
|
||||
t.Log("actual cells:")
|
||||
t.Log(actualStr)
|
||||
expectedStr := ""
|
||||
@@ -859,8 +540,8 @@ func TestGetNextPipes(t *testing.T) {
|
||||
getStyle := func(c *models.Commit) *style.TextStyle { return &style.FgDefault }
|
||||
pipes := getNextPipes(test.prevPipes, test.commit, getStyle)
|
||||
// rendering cells so that it's easier to see what went wrong
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil, BoxDrawingSymbols)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil, BoxDrawingSymbols)
|
||||
actualStr := renderPipeSet(pipes, pool("selected"), nil)
|
||||
expectedStr := renderPipeSet(test.expected, pool("selected"), nil)
|
||||
t.Log("expected cells:")
|
||||
t.Log(expectedStr)
|
||||
t.Log("actual cells:")
|
||||
@@ -881,7 +562,7 @@ func BenchmarkRenderCommitGraph(b *testing.B) {
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle, BoxDrawingSymbols)
|
||||
RenderCommitGraph(commits, hashPool.Add("selected"), getStyle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
)
|
||||
@@ -157,34 +156,12 @@ 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,
|
||||
gui.contentWriter(view),
|
||||
view,
|
||||
func() {
|
||||
// Called before showing the "loading..." indicator: clear the
|
||||
// displayed buffer so only "loading..." is shown. The actual content
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -214,29 +213,6 @@ 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
|
||||
|
||||
// BranchForPullRequest returns the local branch whose pull request would show
|
||||
// the diff in the main view, and "" where no branch does: 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.
|
||||
BranchForPullRequest() string
|
||||
|
||||
// CommitsForPullRequest returns the commits whose combined diff the main view is
|
||||
// showing, newest first as the panel lists them, together with the hash of the
|
||||
// commit that diff starts after: the parent of the oldest of them, where the
|
||||
// pull request has that parent as one of its own commits, and "" where the diff
|
||||
// starts where the pull request itself does.
|
||||
CommitsForPullRequest() ([]*models.Commit, 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,8 +293,6 @@ type TranslationSet struct {
|
||||
UnsupportedGitService string
|
||||
CopyPullRequestURL string
|
||||
OpenPullRequestInBrowser string
|
||||
OpenPullRequestAtSelectedLine string
|
||||
OpenPullRequestAtSelectedLineTooltip string
|
||||
NoPullRequestForBranch string
|
||||
NoBranchOnRemote string
|
||||
Fetch string
|
||||
@@ -312,10 +310,6 @@ type TranslationSet struct {
|
||||
ToggleSelectHunk string
|
||||
SelectHunk string
|
||||
NothingToSelectInDiff string
|
||||
NotAvailableInDiffingMode string
|
||||
NotAvailableForCustomPatch string
|
||||
CommitNotInPullRequest string
|
||||
CommitsNotInPullRequest string
|
||||
SelectLineByLine string
|
||||
ToggleSelectHunkTooltip string
|
||||
ToggleSelectionForPatch string
|
||||
@@ -416,11 +410,6 @@ 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
|
||||
@@ -1478,8 +1467,6 @@ 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`,
|
||||
@@ -1498,10 +1485,6 @@ 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",
|
||||
SelectHunk: "Select hunks",
|
||||
SelectLineByLine: "Select line-by-line",
|
||||
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
|
||||
@@ -1607,11 +1590,6 @@ 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",
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
|
||||
)
|
||||
|
||||
@@ -61,12 +60,6 @@ func (self *TestDriver) click(x, y int) {
|
||||
self.Wait(self.inputDelay)
|
||||
}
|
||||
|
||||
func (self *TestDriver) clickWithModifier(x, y int, modifier gocui.Modifier, what string) {
|
||||
self.SetCaption(fmt.Sprintf("%s-clicking %d, %d", what, x, y))
|
||||
self.gui.ClickWithModifier(x, y, modifier)
|
||||
self.Wait(self.inputDelay)
|
||||
}
|
||||
|
||||
func (self *TestDriver) clickAndHold(x, y int) {
|
||||
self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y))
|
||||
self.mouseX, self.mouseY = x, y
|
||||
|
||||
@@ -47,10 +47,6 @@ func (self *fakeGuiDriver) Click(x, y int) {
|
||||
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) ClickWithModifier(x, y int, modifier gocui.Modifier) {
|
||||
self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y})
|
||||
}
|
||||
|
||||
func (self *fakeGuiDriver) ClickAndHold(x, y int) {
|
||||
self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y})
|
||||
}
|
||||
@@ -204,8 +200,6 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
|
||||
|
||||
viewDriver.
|
||||
Click(1, 2).
|
||||
AltClick(2, 3).
|
||||
ShiftClick(4, 5).
|
||||
FocusInAndClick(3, 4).
|
||||
ClickAndHold(5, 6).
|
||||
MouseMove(7, 8).
|
||||
@@ -213,13 +207,11 @@ func TestViewDriverPointerCoordinates(t *testing.T) {
|
||||
MouseMoveToView(targetViewDriver, 10, 11).
|
||||
ScrollWheelDown()
|
||||
|
||||
assert.Equal(t,
|
||||
[]coordinate{{12, 23}, {13, 24}, {15, 26}, {14, 25}},
|
||||
guiDriver.clickedCoordinates)
|
||||
assert.Equal(t, []coordinate{{12, 23}, {14, 25}}, guiDriver.clickedCoordinates)
|
||||
assert.Equal(t, []coordinate{{16, 27}}, guiDriver.heldCoordinates)
|
||||
assert.Equal(t, []coordinate{{18, 29}, {20, 30}, {51, 62}}, guiDriver.movedCoordinates)
|
||||
assert.Equal(t, []coordinate{{11, 21}}, guiDriver.scrolledCoordinates)
|
||||
assert.Equal(t, 9, guiDriver.onUIThreadCallCount)
|
||||
assert.Equal(t, 7, guiDriver.onUIThreadCallCount)
|
||||
}
|
||||
|
||||
func TestFailingFixture(t *testing.T) {
|
||||
|
||||
@@ -726,25 +726,6 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// AltClick and ShiftClick click with a modifier held down. Both modifiers are
|
||||
// bound to the same gestures, because no single one of them reaches lazygit in
|
||||
// every terminal.
|
||||
func (self *ViewDriver) AltClick(x, y int) *ViewDriver {
|
||||
offsetX, offsetY, _ := self.viewGeometry()
|
||||
|
||||
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModAlt, "Alt")
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) ShiftClick(x, y int) *ViewDriver {
|
||||
offsetX, offsetY, _ := self.viewGeometry()
|
||||
|
||||
self.t.clickWithModifier(offsetX+1+x, offsetY+1+y, gocui.ModShift, "Shift")
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver {
|
||||
offsetX, offsetY, _ := self.viewGeometry()
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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"))
|
||||
},
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
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"))
|
||||
},
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var EditClickedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Alt- or shift-click a line of the main view's diff to open it in the editor, without focusing the view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
|
||||
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
IsFocused()
|
||||
|
||||
// The click points at the line itself, so the main view can stay unfocused
|
||||
// and unselected. You read a diff where it is and click into it.
|
||||
t.Views().Main().
|
||||
AltClick(0, 8).
|
||||
Tap(func() {
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
|
||||
}).
|
||||
SelectionIsHidden()
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused()
|
||||
|
||||
// Shift-click is bound to the same thing, since neither modifier reaches
|
||||
// lazygit in every terminal. A context line names a line of the file like any
|
||||
// other row.
|
||||
t.Views().Main().
|
||||
ShiftClick(0, 9).
|
||||
Tap(func() {
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:4\n"))
|
||||
})
|
||||
|
||||
// A popup taking the focus swallows clicks on the views behind it. This one
|
||||
// stays live, so a diff can still be read and clicked into while a popup is
|
||||
// up.
|
||||
t.Views().Files().
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.Views().Menu().
|
||||
IsFocused()
|
||||
|
||||
t.Views().Main().
|
||||
AltClick(0, 6).
|
||||
Tap(func() {
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:2\n"))
|
||||
})
|
||||
|
||||
t.Views().Menu().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Return)
|
||||
},
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -11,7 +11,6 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.ShowFileTree = false
|
||||
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`)
|
||||
@@ -48,20 +47,6 @@ var NoSelectionOverAConflictHint = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
PressPrimaryAction().
|
||||
Tap(func() {
|
||||
t.ExpectToast(Contains("There is nothing to select here"))
|
||||
}).
|
||||
// A pane with nothing to select still has lines to point at. A modified
|
||||
// click names its own line rather than acting on the selection, so it
|
||||
// opens the file there. The file is in the working tree for a conflict
|
||||
// like this one, holding the modified side; you may want to copy a piece
|
||||
// of it elsewhere before resolving the conflict by deleting the file.
|
||||
//
|
||||
// The cursor moves where a plain click points even here, so the assertion
|
||||
// below says which row the modified click then lands on.
|
||||
Click(0, 17).
|
||||
SelectedLine(Contains("+2")).
|
||||
AltClick(0, 17).
|
||||
Tap(func() {
|
||||
t.FileSystem().FileContent("edit-command", Contains("/repo/bar:1\n"))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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,8 +378,6 @@ 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,
|
||||
@@ -393,7 +391,6 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.DiscardLinesFromACommit,
|
||||
main_view.DragRangeWithAutoscroll,
|
||||
main_view.DragSelectsDiffLineRange,
|
||||
main_view.EditClickedDiffLine,
|
||||
main_view.EditHistoricalDiffLine,
|
||||
main_view.EditHunkInFocusedDiff,
|
||||
main_view.EditSelectedDiffLine,
|
||||
@@ -407,8 +404,6 @@ 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,
|
||||
@@ -444,12 +439,10 @@ 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,
|
||||
|
||||
@@ -28,9 +28,6 @@ type GuiDriver interface {
|
||||
// user typing faster than lazygit processes the input.
|
||||
PressKeysRapidly(...string)
|
||||
Click(int, int)
|
||||
// Click with a keyboard modifier held down, for the gestures that only exist
|
||||
// as a modified click.
|
||||
ClickWithModifier(int, int, gocui.Modifier)
|
||||
ClickAndHold(int, int)
|
||||
MouseMove(int, int)
|
||||
MouseRelease(int, int)
|
||||
|
||||
@@ -1221,16 +1221,6 @@
|
||||
"description": "If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.",
|
||||
"default": true
|
||||
},
|
||||
"commitGraphStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"classic",
|
||||
"detailed"
|
||||
],
|
||||
"description": "How the commit graph is drawn.\nOne of: 'auto' (default) | 'classic' | 'detailed'\n'detailed' connects the lines to the commit circles, and shows exactly where branches fork off and merge. It draws the graph with the git branch drawing symbols (U+F5D0 to U+F60D), so it needs a terminal that draws these itself: kitty, Ghostty, WezTerm (nightly builds), Contour, or VS Code's terminal with GPU acceleration. Other terminals need a font that contains them, such as https://github.com/rbong/flog-symbols.\n'auto' uses 'detailed' if lazygit recognizes the terminal as one that draws these symbols (kitty and Ghostty), and 'classic' otherwise.",
|
||||
"default": "auto"
|
||||
},
|
||||
"commitAuthorShortLength": {
|
||||
"type": "integer",
|
||||
"description": "Length of author name in (non-expanded) commits view. 2 means show initials only.",
|
||||
@@ -3138,20 +3128,6 @@
|
||||
],
|
||||
"default": "0"
|
||||
},
|
||||
"jumpToFile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "\u003cctrl+g\u003e"
|
||||
},
|
||||
"nextMatch": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -12,9 +12,6 @@ gui:
|
||||
- black
|
||||
# Not important in tests but it creates clutter in demos
|
||||
showRandomTip: false
|
||||
# The expected output of the tests draws the commit graph with box drawing
|
||||
# characters, whichever terminal a test runs in
|
||||
commitGraphStyle: classic
|
||||
animateExplosion: false # takes too long
|
||||
git:
|
||||
# We don't want to run any periodic background git commands because it'll introduce race conditions and flakiness.
|
||||
|
||||
Reference in New Issue
Block a user