Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 9e8f3225a6 Link the file names in a diffstat to where each file's diff begins
A diff opens with a diffstat naming every file in it, right above the
diff of each of them. Selecting a commit puts that list in front of you,
naming the same files the menu of the diff's files offers, and the menu
is still the only way to any of them.

Make each of those names a link that goes to where that file's diff
begins, as picking it from the menu does. The panel keeps the focus, so
a file of the commit being read is a click away and the selection stays
on the commit.

The names are found in the output as it is written to the pane, for next
to nothing. 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 and
nothing below is looked at.

The link states the name as the diffstat does, and which file that names
is worked out on the click, against the files the diff turned out to
hold. That is the point at which a name the diffstat cut off behind
"..." or compacted to the "{old => new}" form of a rename can be
recognized at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 164e65ceee Add a menu of the diff's files to jump to one directly
n and N step through the files of a multi-file diff one at a time, which
is a long way to the far end of a commit touching a hundred files.

Bind ctrl+g to a menu of the diff's files, in the order the diff shows
them and by the paths the repo knows them by; picking one goes to where
its diff begins, exactly where stepping to it with n would have left
you. The menu filters as you type, so the file you have in mind is a few
characters away however many the commit touches.

The key works in the panel the diff belongs to as well as in the diff
itself. A commit is read from the commits panel, so being able to jump
from there saves focusing the diff and leaving it again for the next
commit; the diff scrolls to the file and the focus stays in the panel.
It is ctrl+g rather than f because a key that works in every panel has
to be free in all of them, and f is fetch in the files panel and fixup
in the commits panel.

The command applies only while the main view is showing a diff, and is
left out of the keybindings menu where it isn't: over a branch's commit
log, or while a conflicted file has given the main section over to the
merge conflicts view.

The diff is read to the end before the menu is built, as searching it
does: a file below the part that has been read is in neither the list
nor the view. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 61382b8d37 Move where a jump lands into the diff line helper
Where a focus or a click puts the focused main view's selection is
worked out in the diff line helper; where a jump puts it is worked out
on the main view controller, though it is the same question about the
same pane. A jump asked for from anywhere else — from a panel below the
pane, or from a click on a link in the diff — has no way to reach that
answer.

Move it over. The controller keeps placeNavigationTarget as the short
way to say it for the jumps it makes itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 d2543bd7ae Ask a diff where each of its files begins
File navigation works out where the neighbouring file begins by walking
the rows itself, forwards or, more laboriously, backwards. A menu of the
diff's files needs the same rows, all of them at once.

Extract fileStarts, which answers that for the whole diff, and have
navigation pick its neighbour out of the answer. The two then agree on
where a file begins by construction, and the walk backwards over a file
goes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
Stefan HallerandClaude Opus 5 2bf39e0f0c Name the file a diff row belongs to in the repo's terms
The identity of a row is reported in the repo's terms already: the pane
previewing the custom patch shows a diff of the two trees the patch was
materialized into, and the paths of those trees are mapped back to the
repo's files before anything sees them. Asking which file a row belongs
to, which file navigation does, was the one query that skipped that step
and answered with the tree's path.

Pull the mapping out of inRepoTerms so that it can be applied to a bare
path, and put filePaths through it. A menu listing the files of the diff
will want to show them by name; where a renderer states the path of each
side of a change, this also has both halves belong to one file rather
than to the two trees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:26:56 +02:00
32 changed files with 1551 additions and 110 deletions
+1
View File
@@ -697,6 +697,7 @@ keybinding:
- "4"
- "5"
focusMainView: "0"
jumpToFile: <ctrl+g>
nextMatch: "n"
prevMatch: "N"
startSearch: /
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Prev screen mode | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Cancel | |
| `` ? `` | Open keybindings menu | |
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -234,6 +235,7 @@ _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. |
@@ -317,6 +319,7 @@ _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. |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 前の画面モード | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | キャンセル | |
| `` ? `` | キーバインディングメニューを開く | |
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
@@ -203,6 +204,7 @@ _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 `` | コミット | ステージされた変更をコミットします。 |
@@ -294,6 +296,7 @@ _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 `` | コミット | ステージされた変更をコミットします。 |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 이전 스크린 모드 | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 취소 | |
| `` ? `` | 매뉴 열기 | |
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -95,6 +96,7 @@ _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 `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
@@ -189,6 +191,7 @@ _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 `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Vorige scherm modus | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Annuleren | |
| `` ? `` | Open menu | |
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -242,6 +243,7 @@ _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. |
@@ -317,6 +319,7 @@ _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. |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Poprzedni tryb ekranu | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Anuluj | |
| `` ? `` | Otwórz menu przypisań klawiszy | |
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
@@ -110,6 +111,7 @@ _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. |
@@ -212,6 +214,7 @@ _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. |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Modo de tela anterior | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Cancelar | |
| `` ? `` | Abrir o menu de atalhos do teclado | |
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -246,6 +247,7 @@ _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 |
@@ -326,6 +328,7 @@ _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 |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | Предыдущий режим экрана | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | Отменить | |
| `` ? `` | Открыть меню | |
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
@@ -85,6 +86,7 @@ _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. |
@@ -111,6 +113,7 @@ _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. |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 上一屏模式 | |
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 取消 | |
| `` ? `` | 打开菜单 | |
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
@@ -281,6 +282,7 @@ _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 `` | 提交变更 | 提交暂存文件 |
@@ -323,6 +325,7 @@ _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 `` | 提交变更 | 提交暂存文件 |
@@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` _ `` | 上一個螢幕模式 | |
| `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 |
| `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 |
| `` <ctrl+g> `` | Jump to file in diff | Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel. |
| `` <esc> `` | 取消 | |
| `` ? `` | 開啟選單 | |
| `` <ctrl+s> `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 |
@@ -70,6 +71,7 @@ _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 `` | 提交變更 | 提交暫存區變更 |
@@ -345,6 +347,7 @@ _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 `` | 提交變更 | 提交暫存區變更 |
+2
View File
@@ -511,6 +511,7 @@ type KeybindingUniversalConfig struct {
PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"`
JumpToBlock []Keybinding `yaml:"jumpToBlock"`
FocusMainView Keybinding `yaml:"focusMainView"`
JumpToFile Keybinding `yaml:"jumpToFile"`
NextMatch Keybinding `yaml:"nextMatch"`
PrevMatch Keybinding `yaml:"prevMatch"`
StartSearch Keybinding `yaml:"startSearch"`
@@ -1037,6 +1038,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
NextBlockAlt2: Keybinding{"<tab>"},
JumpToBlock: []Keybinding{{"1"}, {"2"}, {"3"}, {"4"}, {"5"}},
FocusMainView: Keybinding{"0"},
JumpToFile: Keybinding{"<ctrl+g>"},
NextMatch: Keybinding{"n"},
PrevMatch: Keybinding{"N"},
StartSearch: Keybinding{"/"},
+61
View File
@@ -3,6 +3,7 @@ package controllers
import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -74,6 +75,14 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type
Description: self.c.Tr.CycleDiffRenderersReverse,
Tooltip: self.c.Tr.CycleDiffRenderersReverseTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.JumpToFile),
Handler: opts.Guards.NoPopupPanel(self.jumpToFileInDiff),
Description: self.c.Tr.JumpToFileInDiff,
DescriptionFunc: self.jumpToFileInDiffDescription,
Tooltip: self.c.Tr.JumpToFileInDiffTooltip,
OpensMenu: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Return),
Handler: self.escape,
@@ -206,6 +215,58 @@ func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason {
return nil
}
// jumpToFileInDiff offers the files of the diff the main section is showing in a menu,
// and scrolls that pane to the file picked. The panel the user is in keeps the focus;
// they are reading the diff from there, and the next commit or file to read is picked
// there too.
func (self *GlobalController) jumpToFileInDiff() error {
pane := self.diffPane()
if pane == nil {
return nil
}
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(pane, self.c.Tr.JumpToFileInDiff)
}
// jumpToFileInDiffDescription qualifies the command's description so that it is listed
// only where it applies. A command with no description is left out of the keybindings
// menu.
//
// It doesn't apply where the main section is showing content that is no diff of the
// panel's — a branch's commit log, the status dashboard, a message. Nor does it while
// the focus is in one of the panes, which bind the key themselves; the menu would
// otherwise offer it twice there, once for the pane and once among the global keys.
//
// The static Description stays as it is: the cheatsheets are generated from that, and
// they document what a key does rather than when it applies.
func (self *GlobalController) jumpToFileInDiffDescription() string {
_, focusIsInAPane := self.c.Context().Current().(*context.MainContext)
if focusIsInAPane || self.diffPane() == nil {
return ""
}
return self.c.Tr.JumpToFileInDiff
}
// diffPane returns the pane of the main section showing the diff of the panel the user
// is in, and nil when neither of them is showing one. A pane is cleared as it is
// emptied, so a pane that says it is showing a diff is showing one. Its window also has
// to be showing the pane. Resolving a conflicted file puts the merge conflicts view
// there instead, and the pane behind it goes on holding the diff it last rendered.
//
// Where both panes show a diff — the unstaged and staged sides of a file — the answer
// is the upper one, the pane the keys for scrolling the section act on.
func (self *GlobalController) diffPane() *context.MainContext {
for _, pane := range []*context.MainContext{
self.c.Contexts().Normal, self.c.Contexts().NormalSecondary,
} {
onScreen := self.c.Helpers().Window.GetContextForWindow(pane.GetWindowName()) == pane
if onScreen && pane.ContentIsDiff() {
return pane
}
}
return nil
}
func (self *GlobalController) createOptionsMenu() error {
return (&OptionsMenuAction{c: self.c}).Call()
}
@@ -0,0 +1,70 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// OpenJumpToFileMenu offers the files of the diff the given pane is showing in a menu,
// so that one of them can be gone to directly rather than by stepping through the diff
// a file at a time. Picking one goes to where that file's diff begins, the way stepping
// to it with next-file does: the selection moves there in a focused pane, and an
// unfocused one scrolls the file to the top.
//
// The diff is read to the end before the menu is built: a file below the part of it that
// has been read so far is in neither the list nor the view, and reaching the far end of
// a long diff is what the menu is for.
func (self *DiffLineHelper) OpenJumpToFileMenu(pane types.DiffPaneContext, title string) error {
manager := self.c.GetViewBufferManagerForView(pane.GetView())
if manager == nil {
return nil
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error { return self.showJumpToFileMenu(pane, title) })
})
return nil
}
// showJumpToFileMenu offers the diff's files by the paths git names them by. Each item
// names its file rather than the row that file begins at, so that a diff re-rendered
// while the menu is up is jumped into at the row the file begins at now.
//
// A menu offering the one file of a single-file diff would be a menu with nothing to
// choose, so it says what it found instead. It says it here rather than as the key's
// disabled reason because how many files there are is only known once the diff has been
// read to the end, which is too much to do for every keypress that asks whether a key
// applies.
func (self *DiffLineHelper) showJumpToFileMenu(pane types.DiffPaneContext, title string) error {
view := pane.GetView()
files := self.FilesInDiff(view)
if len(files) == 0 {
return nil
}
if len(files) == 1 {
self.c.ErrorToast(self.c.Tr.DisabledMenuItemPrefix + self.c.Tr.OnlyOneFileInDiff)
return nil
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
menuItems := lo.Map(files, func(path string, _ int) *types.MenuItem {
label := repoRelativePath(worktreePath, path)
if label == "" {
label = path
}
return &types.MenuItem{
Label: label,
OnPress: func() error {
if target, ok := self.StartOfFileInDiff(view, path); ok {
self.PlaceNavigationTarget(pane, target, true)
}
return nil
},
}
})
return self.c.Menu(types.CreateMenuOptions{
Title: title,
Items: menuItems,
FilterAsYouType: true,
})
}
@@ -162,11 +162,19 @@ func (self *selectedDiffLines) covers(info types.DiffLineInfo) bool {
func repoRelativePaths(worktreePath string, paths []string) []string {
relPaths := make([]string, 0, len(paths))
for _, path := range paths {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
if relPath := repoRelativePath(worktreePath, path); relPath != "" {
relPaths = append(relPaths, relPath)
}
relPaths = append(relPaths, filepath.ToSlash(relPath))
}
return relPaths
}
// repoRelativePath turns one such path into the repo-relative one, and is "" for a path
// that lies outside the worktree.
func repoRelativePath(worktreePath string, path string) string {
relPath, err := filepath.Rel(worktreePath, path)
if err != nil || strings.HasPrefix(relPath, "..") {
return ""
}
return filepath.ToSlash(relPath)
}
@@ -79,16 +79,26 @@ func (self *DiffLineHelper) ChangeLineOrdinals(
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
// prefixes and so needs nothing.
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
toRepoTerms := self.repoTermsMapper(view)
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
info.Path = toRepoTerms(info.Path)
return info
})
}
// repoTermsMapper returns how a path recovered from view is brought into the repo's
// terms, for the callers that have a path rather than a whole identity to bring. The
// mapping is worked out once, per view rather than per path.
func (self *DiffLineHelper) repoTermsMapper(view *gocui.View) func(string) string {
if !self.ShowsCustomPatch(view) {
return infos
return func(path string) string { return path }
}
worktreePath := self.c.Git().RepoPaths.WorktreePath()
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
return info
})
return func(path string) string {
return repoPathOfTreePath(path, treesDir, worktreePath)
}
}
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
@@ -336,62 +346,98 @@ func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, f
return view.ViewLineForBufferLine(target)
}
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
// empty for a row whose identity couldn't be recovered.
// FilesInDiff lists the files of view's (possibly multi-file) rendered diff, in the
// order it shows them, by the paths of the repo's files. It is what a menu offering to
// jump between them is built from; jumping to one of them goes to StartOfFileInDiff.
func (self *DiffLineHelper) FilesInDiff(view *gocui.View) []string {
return lo.Map(fileStarts(self.filePaths(view)),
func(start diffFileStart, _ int) string { return start.path })
}
// StartOfFileInDiff returns the view line the given file's section of view's rendered
// diff begins at. That is the row file navigation lands on, so jumping to a file from a
// menu and stepping to it with next-file land in the same place. ok is false for a file
// the diff doesn't show, e.g. because it was re-rendered since the file was listed.
func (self *DiffLineHelper) StartOfFileInDiff(view *gocui.View, path string) (int, bool) {
start, ok := lo.Find(fileStarts(self.filePaths(view)), func(start diffFileStart) bool {
return start.path == path
})
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(start.row)
}
// filePaths resolves view's rendered diff to the path each buffer line belongs to, in
// the repo's terms, and empty for a row whose identity couldn't be recovered. Naming the
// files the way the rest of the queries name them means a row of the custom patch's
// preview belongs to the repo's file rather than to the copy of it in the tree the patch
// was materialized into, so that both halves of a change belong to the same file however
// the diff renderer states them.
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
resolved := self.resolveDiffLines(view.DiffLineContents())
toRepoTerms := self.repoTermsMapper(view)
paths := make([]string, len(resolved))
for i, row := range resolved {
if row.ok {
paths[i] = row.info.Path
paths[i] = toRepoTerms(row.info.Path)
}
}
return paths
}
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
// a row no backend could place), the first located row of the file adjacent to `from`
// in the given direction — the row file navigation lands on. It is the pure index
// arithmetic behind AdjacentFile.
// diffFileStart is where one file of a diff begins: the path of the file, and the row
// of the diff its section starts at.
type diffFileStart struct {
path string
row int
}
// fileStarts finds, in a diff whose lines carry the file path they belong to (empty for
// a row no backend could place), where each file of it begins, in the order the diff
// shows them.
//
// A file is identified by its path, so we look for where the path changes, skipping
// rows that carry none: those are the blank separator rows between files, or the
// header rows of a diff renderer that doesn't state which file its headers belong to.
// So the landing row is the file's header wherever the source says so — a parseable
// buffer, or a renderer that tags its headers — and the file's first content line
// otherwise, which is an accepted degradation.
// A file is identified by its path, and the rows showing it are consecutive, so a path
// differing from the one before it begins a file. Rows carrying no path are passed over:
// those are the blank separator rows between files, or the header rows of a diff
// renderer that doesn't state which file its headers belong to. So a file begins at its
// header wherever the source says so (a parseable buffer, or a renderer that tags its
// headers), and at its first content line otherwise, which is an accepted degradation.
func fileStarts(paths []string) []diffFileStart {
starts := []diffFileStart{}
previousPath := ""
for row, path := range paths {
if path == "" || path == previousPath {
continue
}
previousPath = path
starts = append(starts, diffFileStart{path: path, row: row})
}
return starts
}
// fileStart returns where the file adjacent to `from` in the given direction begins —
// the row file navigation lands on. It is the pure index arithmetic behind AdjacentFile.
// ok is false at the first or last file of the diff.
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
if forward {
for i := from; i < len(paths); i++ {
if paths[i] != "" && paths[i] != anchorPath {
return i, true
}
}
starts := fileStarts(paths)
_, anchor, ok := lo.FindIndexOf(starts, func(start diffFileStart) bool {
return start.path == anchorPath
})
if !ok {
return 0, false
}
// Walk back past the current file (its rows and any unlocated ones) to the previous
// file's last located row, then back over that whole file, landing on its first.
i := from
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
i--
}
if i < 0 {
target := anchor + lo.Ternary(forward, 1, -1)
if target < 0 || target >= len(starts) {
return 0, false
}
prevPath := paths[i]
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
i--
}
for paths[i] != prevPath {
i++
}
return i, true
return starts[target].row, true
}
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
@@ -48,6 +48,41 @@ func TestChangeBlockStart(t *testing.T) {
}
}
func TestFileStarts(t *testing.T) {
scenarios := []struct {
name string
paths []string
expected []diffFileStart
}{
{
name: "a parseable diff begins each file at its header",
paths: []string{"a", "a", "a", "b", "b"},
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
},
{
name: "a diff whose headers carry no path begins each file at its first content line",
paths: []string{"", "", "a", "a", "", "", "b", "b"},
expected: []diffFileStart{{path: "a", row: 2}, {path: "b", row: 6}},
},
{
name: "an unlocated row within a file doesn't begin another one",
paths: []string{"a", "", "a", "b"},
expected: []diffFileStart{{path: "a", row: 0}, {path: "b", row: 3}},
},
{
name: "a diff with no located rows shows no files",
paths: []string{"", ""},
expected: []diffFileStart{},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, fileStarts(s.paths))
})
}
}
func TestFileStart(t *testing.T) {
// A parseable two-file diff: every row carries its file's path, headers included,
// as the buffer parser reports it.
@@ -33,6 +33,21 @@ func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode {
return git_commands.DiffModeRendered
}
// DiffRowsCanBePlaced reports whether the rows of the diff the main view is about to be
// given can be placed in the file they show. git's own diff describes itself, whether it
// is what the user configured or what MainViewDiffMode is about to substitute for a
// rendering that can't be acted on; any other rendering says where its rows belong only
// if it states records for them.
//
// It is what anything that means to go from a row back to the file it shows has to ask
// first: with neither records nor a diff that describes itself, there is nothing to go
// on, and offering the user the way there would be offering nothing.
func (self *DiffLineHelper) DiffRowsCanBePlaced() bool {
return !self.diffNeedsMetadata() ||
self.MainViewDiffMode() == git_commands.DiffModeRaw ||
self.diffRendererEmitsMetadata()
}
// RenderFocusedMainViewAgain has the panel beneath the focused main view render its
// diff again — which, the main view now holding focus, is git's own diff rather than
// the renderer's — and calls place once that is on screen.
@@ -7,9 +7,9 @@ import (
"github.com/samber/lo"
)
// Putting a selection in the focused main view: where it starts out, and how it is
// widened to a whole change block. Both are answered from what the view is showing,
// as recovered by the queries next door.
// Putting a selection in the focused main view: where it starts out, where a jump
// leaves it, and how it is widened to a whole change block. All three are answered
// from what the view is showing, as recovered by the queries next door.
// EstablishSelection turns on the focused main view's selection once the view has
// been focused. clickedViewLine is the view line a click pointed at, or -1 for
@@ -63,6 +63,66 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext,
self.ShowSelectionAtLine(view, target, false)
}
// PlaceNavigationTarget moves the pane's selection to the row a jump found, bringing
// it on screen if it isn't already. With no selection to move — a pane that isn't
// focused, or one showing a diff with nothing selectable in it — the row goes to the
// top of the view instead, that being all a jump can do there.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *DiffLineHelper) PlaceNavigationTarget(
pane types.DiffPaneContext, target int, alignTop bool,
) {
view := pane.GetView()
if !view.Highlight {
view.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(pane, target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.CollapseNonStickyRange(pane)
if pane.DiffSelectState().Mode == types.DiffSelectModeHunk {
self.SelectChangeBlock(pane, target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.ShowSelectionAtLine(view, target, true)
}
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *DiffLineHelper) scrollTargetToTop(pane types.DiffPaneContext, target int) {
view := pane.GetView()
originY, height := pane.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
}
// CollapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *DiffLineHelper) CollapseNonStickyRange(pane types.DiffPaneContext) {
sel := pane.DiffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
pane.GetView().CancelRangeSelect()
}
}
// changeToSelectOnScreen returns the change line keyboard focus establishes the
// selection on. In hunk mode that is the first block that begins on screen, so that
// the block being offered up is one the user can see the extent of, falling back to a
@@ -0,0 +1,339 @@
package helpers
import (
"bytes"
"io"
"regexp"
"strings"
"sync/atomic"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
// A diff opens with a diffstat naming every file in it, above the diff of each of
// them. Here each of those names is made a link to where that file's diff begins, so
// that a file of a long diff can be gone to by clicking the line that names it.
//
// The names are recognized in the output as it is written to the pane, where they cost
// next to nothing to find. The diffstat is git's own text whichever renderer the diff
// goes through — delta, diff-so-fancy and difftastic all pass it on untouched — and it
// comes first, so the scan for it ends with it.
// DiffStatLinkScheme names a link to a file of the diff the pane is showing, as
// lazygit-edit names one that opens a file in the editor. The link is never handed to
// the terminal — gocui takes the escape sequence out of the content and gives the URL
// back when the cell it covers is clicked — so the path in it needs no escaping.
const DiffStatLinkScheme = "lazygit-diff-file://"
// diffStatEntryPattern matches a line of a diffstat and captures the path it states.
// Such a line holds the name of the file, padded out to the width of the longest, then
// the number of lines it changes (or "Bin" for a binary file) and the graph of them.
//
// The name is captured greedily, so that the separator found is the last one on the
// line rather than one in a file name that contains " | " itself.
var diffStatEntryPattern = regexp.MustCompile(`^ (.*[^ ]) +\| +(?:Bin|\d+)`)
// DiffStatLinkWriter hands a pane's content on to it, turning the file names in the
// diffstat the content opens with into links (see DiffStatLinkScheme).
type DiffStatLinkWriter struct {
writer io.Writer
// Whether the diffstat is still to come, is being written now, or is behind us. It
// is behind us once a line comes that is no entry of it, or that the diff proper
// begins with, and nothing past that is looked at. A name down there names the file
// the reader is already in.
//
// It is atomic because the render is begun on the UI thread while the content of
// it arrives on the goroutine reading the command's output.
state atomic.Int32
}
type diffStatState int32
const (
diffStatToCome diffStatState = iota
inDiffStat
diffStatDone
)
func NewDiffStatLinkWriter(writer io.Writer) *DiffStatLinkWriter {
return &DiffStatLinkWriter{writer: writer}
}
// BeginRender starts a fresh render, whose own diffstat is the one to look for. It is
// called as the render is asked for, before any of it is written.
//
// linkFiles says whether this render is one whose file names lead anywhere: the pane's
// own diff, whose rows can be placed in the files they show. Either is passed through
// untouched without it. Content that is no diff of the panel's — a commit log, a
// message — has no diffstat in it, and a line of one that happens to read like an entry
// of a diffstat names no file to go to. A rendering whose rows nothing can place does
// have the files in it, but nothing to find the one a name stands for with.
func (self *DiffStatLinkWriter) BeginRender(linkFiles bool) {
self.setState(lo.Ternary(linkFiles, diffStatToCome, diffStatDone))
}
func (self *DiffStatLinkWriter) getState() diffStatState {
return diffStatState(self.state.Load())
}
func (self *DiffStatLinkWriter) setState(state diffStatState) {
self.state.Store(int32(state))
}
func (self *DiffStatLinkWriter) Write(p []byte) (int, error) {
linked := self.withFileNameLinked(p)
written, err := self.writer.Write(linked)
if err != nil {
return 0, err
}
if written < len(linked) {
return 0, io.ErrShortWrite
}
// The caller is owed an answer about what it gave us, not about what we passed on.
return len(p), nil
}
// withFileNameLinked returns the given line of the render with the name in it linked,
// where the line is an entry of the diffstat.
func (self *DiffStatLinkWriter) withFileNameLinked(line []byte) []byte {
state := self.getState()
if state == diffStatDone {
return line
}
if beginsTheDiffItself(line) {
// A diffstat that hasn't come by now isn't coming: the pane is showing a diff
// that was asked for without one.
self.setState(diffStatDone)
return line
}
match := diffStatEntry(line)
if match == nil {
if state == inDiffStat {
self.setState(diffStatDone)
}
return line
}
self.setState(inDiffStat)
start, end := match[2], match[3]
// The link states the name as it reads on screen, so that a renderer that colors
// the diffstat doesn't put escape sequences into it.
name := utils.Decolorise(string(line[start:end]))
linked := make([]byte, 0, len(line)+len(name)+32)
linked = append(linked, line[:start]...)
linked = append(linked, style.PrintHyperlink(string(line[start:end]), DiffStatLinkScheme+name)...)
return append(linked, line[end:]...)
}
// diffLineRecordOpener opens an OSC 1717 record, ahead of the version whose fields the
// record states (see parseDiffLineMetadata).
const diffLineRecordOpener = "\x1b]1717;"
// beginsTheDiffItself reports whether the line is one of the diff proper rather than
// one of the diffstat above it: git's own header for a file, or a line a renderer
// states a record about (see statesADiffLine).
func beginsTheDiffItself(line []byte) bool {
return bytes.HasPrefix(line, []byte("diff --")) || statesADiffLine(line)
}
// diffLineRecordKinds are the kinds of record a renderer states about a line of the
// diff itself. A line stating one of them is below the whole diffstat, which is what
// makes them the end of the search for it.
//
// They are listed here rather than read off the parser's table (see
// diffLineTypeFromMetadata), which answers a different question: whether a record can be
// read at all. A kind the protocol gains for something above the diff — one renderer
// stated the commit line — would belong in that table and not in this list, and taking
// the table for this would end the search where the diffstat hasn't even begun. Every
// kind of the protocol is held against this list by a test.
var diffLineRecordKinds = []string{"c", "a", "d", "f", "h"}
// statesADiffLine reports whether the line carries a record in which a diff renderer
// states which line of which file it is rendering. Those records are about the lines of
// the diff, and the diffstat comes before all of them.
//
// The kind the record states has to be one of diffLineRecordKinds. A record of any
// other kind says nothing about where the diffstat ends, so the search goes on past it.
//
// The version the record opens with is passed over rather than read. This asks which
// lines a renderer states records for, and the answer holds whichever version of the
// protocol it speaks. A record with nothing after the version is the handshake a
// renderer announces itself with, which is about no line, so the search goes on past it.
func statesADiffLine(line []byte) bool {
for rest := line; ; {
at := bytes.Index(rest, []byte(diffLineRecordOpener))
if at == -1 {
return false
}
rest = rest[at+len(diffLineRecordOpener):]
// The record reads <version>;<kind>;, and the kind is a single character.
digits := 0
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
digits++
}
kind := digits + 1
if digits == 0 || kind+1 >= len(rest) || rest[digits] != ';' || rest[kind+1] != ';' {
continue
}
if lo.Contains(diffLineRecordKinds, string(rest[kind:kind+1])) {
return true
}
}
}
// diffStatEntry matches line against diffStatEntryPattern, behind the two checks that
// answer for nearly every line of a diff without the pattern being run at all: an entry
// of a diffstat is indented by a space, and holds the separator. The indices it returns
// are into the whole line, whatever the match was made past.
func diffStatEntry(line []byte) []int {
start := handshakeEnd(line)
rest := line[start:]
if len(rest) == 0 || rest[0] != ' ' || bytes.IndexByte(rest, '|') == -1 {
return nil
}
match := diffStatEntryPattern.FindSubmatchIndex(rest)
for i := range match {
if match[i] >= 0 {
match[i] += start
}
}
return match
}
// handshakeEnd returns where the record a renderer announces itself with ends, for a
// line that opens with one, and 0 for every other line.
//
// A renderer writes the handshake before anything else and with no newline after it,
// so it lands at the start of the first line of its output. For a diff with nothing
// above its diffstat — the diff of a range of commits, or of a stash — that is the line
// naming the first file in it, and the entry begins after the record rather than at the
// start of the line.
func handshakeEnd(line []byte) int {
if !bytes.HasPrefix(line, []byte(diffLineRecordOpener)) {
return 0
}
after := len(diffLineRecordOpener)
for after < len(line) && line[after] >= '0' && line[after] <= '9' {
after++
}
// Either terminator ends a record. One that goes on into a field instead is about
// a line of the diff, which is below the whole diffstat and no entry of it.
switch {
case after < len(line) && line[after] == '\x07':
return after + 1
case after+1 < len(line) && line[after] == '\x1b' && line[after+1] == '\\':
return after + 2
}
return 0
}
// JumpToFileNamedInDiffStat goes to the file of the pane's diff that the given diffstat
// entry names, for a click on the link made for that entry. It lands the way picking
// the file from the menu of the diff's files does.
//
// The diff is read to the end first, as it is for that menu. The diffstat is on screen
// only while the view is at the top of the diff, so the file clicked is nearly always
// below the part of it that has been read.
func (self *DiffLineHelper) JumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
manager := self.c.GetViewBufferManagerForView(pane.GetView())
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
self.jumpToFileNamedInDiffStat(pane, entry)
return nil
})
})
}
func (self *DiffLineHelper) jumpToFileNamedInDiffStat(pane types.DiffPaneContext, entry string) {
view := pane.GetView()
worktreePath := self.c.Git().RepoPaths.WorktreePath()
files := self.FilesInDiff(view)
names := lo.Map(files, func(file string, _ int) string {
return repoRelativePath(worktreePath, file)
})
index, ok := fileNamedByDiffStatEntry(entry, names)
if !ok {
self.c.ErrorToast(utils.ResolvePlaceholderString(
self.c.Tr.NoFileInDiffNamed, map[string]string{"path": entry}))
return
}
if target, ok := self.StartOfFileInDiff(view, files[index]); ok {
self.PlaceNavigationTarget(pane, target, true)
}
}
// fileNamedByDiffStatEntry returns which of the diff's files a diffstat entry names.
//
// An entry states the path as the diffstat has room for it rather than as git names
// the file. A path too long for the column is cut off on the left behind "...", and a
// rename is compacted to the "{old => new}" form. So the name is looked for among the
// files the diff turned out to hold, whole and then as the end of one, and is taken
// only where it names a single file.
func fileNamedByDiffStatEntry(entry string, paths []string) (int, bool) {
name := renamedTo(strings.TrimSpace(entry))
if index, ok := theOneMatching(paths, func(p string) bool { return p == name }); ok {
return index, true
}
// Where the diffstat cut the path off, what is left is the end of it. The cut is at
// a directory boundary where there is room for one, and inside the file name where
// there isn't.
tail := strings.TrimPrefix(name, "...")
return theOneMatching(paths, func(p string) bool { return strings.HasSuffix(p, tail) })
}
// renamedTo returns the path a diffstat entry for a rename leaves the file at, and the
// entry itself for any other one. A rename states both paths, with whatever they have
// in common written once: "dir/{old => new}/file", or "old => new" where they share
// nothing. The part shared with the old path is gone along with the "{" when the entry
// is cut off on the left, which leaves a path to match the end of.
func renamedTo(entry string) string {
const arrow = " => "
at := strings.Index(entry, arrow)
if at == -1 {
return entry
}
shared := ""
if brace := strings.Index(entry[:at], "{"); brace != -1 {
shared = entry[:brace]
}
renamed := entry[at+len(arrow):]
if closing := strings.Index(renamed, "}"); closing != -1 {
return shared + renamed[:closing] + renamed[closing+1:]
}
return shared + renamed
}
// theOneMatching returns the index of the one element the predicate holds for, and
// false where it holds for none of them or for several.
func theOneMatching(paths []string, matches func(string) bool) (int, bool) {
found := -1
for i, candidate := range paths {
if !matches(candidate) {
continue
}
if found != -1 {
return 0, false
}
found = i
}
return found, found != -1
}
@@ -0,0 +1,312 @@
package helpers
import (
"bytes"
"fmt"
"slices"
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
// link is the line the writer is expected to produce for a diffstat entry: the space
// it is indented by, the name linked, and the rest of the line as it came.
func link(name string, rest string) string {
return " " + style.PrintHyperlink(name, DiffStatLinkScheme+name) + rest
}
// record is the OSC 1717 record a diff renderer speaking the given version of the
// protocol states a line of the given kind with, as it precedes that line in its
// output.
func record(version string, kind string) string {
return fmt.Sprintf("%s%s;%s;;;pkg/gui.go\x1b\\", diffLineRecordOpener, version, kind)
}
// handshake is the record a renderer announces the protocol with: the version it
// speaks, and nothing about any line. Renderers end their records with either
// terminator, so both turn up.
var (
handshake = diffLineRecordOpener + "1\x1b\\"
handshakeBel = diffLineRecordOpener + "1\x07"
)
func TestDiffStatLinkWriter(t *testing.T) {
scenarios := []struct {
name string
linkFiles bool
lines []string
expected []string
}{
{
name: "links the entries of the diffstat, and nothing after it",
linkFiles: true,
lines: []string{
"commit 1234567",
"",
" A commit message",
"",
" pkg/gui.go | 12 ++++++------",
" dir/other.go | 3 ++-",
" 2 files changed, 8 insertions(+), 7 deletions(-)",
"",
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
expected: []string{
"commit 1234567",
"",
" A commit message",
"",
link("pkg/gui.go", " | 12 ++++++------"),
link("dir/other.go", " | 3 ++-"),
" 2 files changed, 8 insertions(+), 7 deletions(-)",
"",
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
},
{
name: "links a binary file, a file that changes nothing, and a name with spaces",
linkFiles: true,
lines: []string{
" logo.png | Bin 0 -> 1234 bytes",
" script.sh | 0",
" my file.txt | 2 +-",
},
expected: []string{
link("logo.png", " | Bin 0 -> 1234 bytes"),
link("script.sh", " | 0"),
link("my file.txt", " | 2 +-"),
},
},
{
name: "stops looking once the diff itself has begun",
linkFiles: true,
lines: []string{
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
expected: []string{
"diff --git a/pkg/gui.go b/pkg/gui.go",
" a context line that reads like an entry | 3 ++-",
},
},
{
name: "stops looking at the first line of the diff a renderer states",
linkFiles: true,
lines: []string{
" pkg/gui.go | 1 +",
record("1", "c") + " a context line of the diff",
" this/looks/like/a/diff/stat | 2 +",
},
expected: []string{
link("pkg/gui.go", " | 1 +"),
record("1", "c") + " a context line of the diff",
" this/looks/like/a/diff/stat | 2 +",
},
},
{
// A renderer is free to state records about something that is no line of
// the diff, and one has stated the commit line above it. The diffstat is
// below such a record as much as it is below the handshake.
name: "keeps looking past a record of a kind it doesn't know",
linkFiles: true,
lines: []string{
record("1", "C") + "commit 1234567",
" pkg/gui.go | 1 +",
},
expected: []string{
record("1", "C") + "commit 1234567",
link("pkg/gui.go", " | 1 +"),
},
},
{
name: "stops for a record of a protocol version it doesn't read",
linkFiles: true,
lines: []string{
record("7", "f") + "── pkg/gui.go ──",
" this/looks/like/a/diff/stat | 2 +",
},
expected: []string{
record("7", "f") + "── pkg/gui.go ──",
" this/looks/like/a/diff/stat | 2 +",
},
},
{
name: "keeps looking past the handshake, which states no line",
linkFiles: true,
lines: []string{
handshake,
" pkg/gui.go | 1 +",
},
expected: []string{
handshake,
link("pkg/gui.go", " | 1 +"),
},
},
{
// A diff with nothing above its diffstat. The handshake is written with no
// newline after it, so it runs into the entry naming the first file.
name: "links an entry the handshake runs into",
linkFiles: true,
lines: []string{
handshake + " pkg/gui.go | 1 +",
" dir/other.go | 2 +-",
},
expected: []string{
handshake + link("pkg/gui.go", " | 1 +"),
link("dir/other.go", " | 2 +-"),
},
},
{
name: "links an entry a handshake ended with a BEL runs into",
linkFiles: true,
lines: []string{
handshakeBel + " pkg/gui.go | 1 +",
},
expected: []string{
handshakeBel + link("pkg/gui.go", " | 1 +"),
},
},
{
name: "leaves a render whose file names lead nowhere alone",
linkFiles: false,
lines: []string{
" pkg/gui.go | 12 ++++++------",
},
expected: []string{
" pkg/gui.go | 12 ++++++------",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
buffer := &bytes.Buffer{}
writer := NewDiffStatLinkWriter(buffer)
writer.BeginRender(scenario.linkFiles)
for _, line := range scenario.lines {
written, err := writer.Write([]byte(line + "\n"))
assert.NoError(t, err)
// The writer answers for what it was given, not for what it passed on.
assert.Equal(t, len(line)+1, written)
}
assert.Equal(t, strings.Join(scenario.expected, "\n")+"\n", buffer.String())
})
}
}
func TestDiffStatLinkWriterStartsLookingAgainWithEachRender(t *testing.T) {
buffer := &bytes.Buffer{}
writer := NewDiffStatLinkWriter(buffer)
for range 2 {
buffer.Reset()
writer.BeginRender(true)
_, _ = writer.Write([]byte(" pkg/gui.go | 1 +\n"))
_, _ = writer.Write([]byte(" 1 file changed, 1 insertion(+)\n"))
assert.Equal(t, link("pkg/gui.go", " | 1 +")+
"\n 1 file changed, 1 insertion(+)\n", buffer.String())
}
}
func TestFileNamedByDiffStatEntry(t *testing.T) {
paths := []string{
"pkg/gui.go",
"pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
"vendor/github.com/gdamore/tcell/v3/AUTHORS",
"pkg/gocui/AUTHORS",
"renamed.txt",
"a/very/deeply/nested/directory/structure/some_long_file_name.txt",
}
scenarios := []struct {
name string
entry string
expected string
}{
{
name: "a path the diffstat had room for",
entry: "pkg/gui.go",
expected: "pkg/gui.go",
},
{
name: "a path cut off at a directory boundary",
entry: ".../tests/main_view/jump_to_a_file_of_the_diff.go",
expected: "pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go",
},
{
name: "a path cut off inside the file name",
entry: "..._long_file_name.txt",
expected: "a/very/deeply/nested/directory/structure/some_long_file_name.txt",
},
{
name: "a rename, stated as the part the two paths share",
entry: "vendor/github.com/gdamore/tcell/{v2 => v3}/AUTHORS",
expected: "vendor/github.com/gdamore/tcell/v3/AUTHORS",
},
{
name: "a rename whose shared part was cut off along with the brace",
entry: ".../github.com/jesseduffield => pkg}/gocui/AUTHORS",
expected: "pkg/gocui/AUTHORS",
},
{
name: "a rename of paths that share nothing",
entry: "original.txt => renamed.txt",
expected: "renamed.txt",
},
{
name: "a name of no file of the diff",
entry: "pkg/nowhere.go",
expected: "",
},
{
name: "a name several files of the diff end with",
entry: "AUTHORS",
expected: "",
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
index, ok := fileNamedByDiffStatEntry(scenario.entry, paths)
if scenario.expected == "" {
assert.False(t, ok)
return
}
assert.True(t, ok)
assert.Equal(t, scenario.expected, paths[index])
})
}
}
// TestEveryRecordKindIsWeighedAgainstTheDiffStat fails when the protocol gains a kind
// of record that nobody has placed relative to the diffstat. Where a line stating that
// kind can only come below the diffstat, it ends the search for it and belongs in
// diffLineRecordKinds; where it can come above the diff — a record about the commit,
// say — it says nothing about where the diffstat ends, and belongs in the list here.
//
// The parser takes the kind as a field rather than a table, so the kinds it reads are
// found by asking it about each character in turn.
func TestEveryRecordKindIsWeighedAgainstTheDiffStat(t *testing.T) {
kindsAboveTheDiff := []string{}
printable := lo.RangeFrom(byte(' '), 0x7f-' ')
kindsTheParserReads := lo.FilterMap(printable, func(char byte, _ int) (string, bool) {
kind := string([]byte{char})
_, ok := diffLineTypeFromMetadata(kind)
return kind, ok
})
assert.ElementsMatch(t,
append(slices.Clone(diffLineRecordKinds), kindsAboveTheDiff...),
kindsTheParserReads,
"a kind of record has been added to the protocol without being weighed "+
"against the diffstat; see this test's comment for where it belongs")
}
+13 -52
View File
@@ -159,6 +159,15 @@ 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,
@@ -838,50 +847,12 @@ func (self *MainViewController) prevFile() error {
return nil
}
// placeNavigationTarget moves the selection to the row a jump found, bringing it on
// screen if it isn't already.
//
// alignTop asks for the target to become the view's top line, so that everything that
// begins there is on screen. It only applies to a target the view has to scroll to: a
// jump to something already on screen leaves the view alone, there being nothing to
// gain from moving what the user is looking at. In hunk mode what ends up selected is
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) {
v := self.context.GetView()
if !v.Highlight {
v.SetOrigin(0, target)
return
}
if alignTop {
self.scrollTargetToTop(target)
}
// Jumping to another block or file moves the cursor without shift held, so a
// range that grows only while shift is held collapses rather than stretching all
// the way to the target. A sticky range stretches instead; this is the point of
// being sticky.
self.collapseNonStickyRange()
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
self.selectHunkAround(target, true)
return
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
self.c.Helpers().DiffLine.ShowSelectionAtLine(v, target, true)
self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop)
}
// scrollTargetToTop scrolls the given row of the diff to the top of the view, leaving
// the view where it is when that row is on screen already. The last screenful of the
// diff is as far as it goes, so that the view doesn't scroll past the end of what it is
// showing.
func (self *MainViewController) scrollTargetToTop(target int) {
view := self.context.GetView()
originY, height := self.context.GetViewTrait().ViewPortYBounds()
if target >= originY && target < originY+height {
return
}
view.SetOriginY(min(target, max(0, view.ViewLinesHeight()-height)))
func (self *MainViewController) openJumpToFileMenu() error {
return self.c.Helpers().DiffLine.OpenJumpToFileMenu(self.context, self.c.Tr.JumpToFile)
}
// moveCursor moves the selection cursor by delta view lines (negative = up), with the
@@ -913,17 +884,7 @@ func (self *MainViewController) collapseForLineMove() {
self.context.GetView().CancelRangeSelect()
return
}
self.collapseNonStickyRange()
}
// collapseNonStickyRange drops a range that only grows while shift is held back to a
// single line at the cursor.
func (self *MainViewController) collapseNonStickyRange() {
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky {
sel.Mode = types.DiffSelectModeLine
self.context.GetView().CancelRangeSelect()
}
self.c.Helpers().DiffLine.CollapseNonStickyRange(self.context)
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
+26 -11
View File
@@ -82,6 +82,9 @@ type Gui struct {
statusManager *status.StatusManager
waitForIntro sync.WaitGroup
viewBufferManagerMap map[string]*tasks.ViewBufferManager
// holds a mapping of the main section's view names to the writers that link the
// files named in the diffstat of what is rendered into them
diffStatLinkWriterMap map[string]*helpers.DiffStatLinkWriter
// holds a mapping of view names to ptmx's. This is for rendering command outputs
// from within a pty. The point of keeping track of them is so that if we re-size
// the window, we can tell the pty it needs to resize accordingly.
@@ -415,6 +418,17 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
return gui.helpers.Files.EditFiles([]string{filepath})
}
if entry, ok := strings.CutPrefix(url, helpers.DiffStatLinkScheme); ok {
view, err := gui.g.View(viewname)
if err != nil {
return nil
}
if pane := gui.mainContextForView(view); pane != nil {
gui.helpers.DiffLine.JumpToFileNamedInDiffStat(pane, entry)
}
return nil
}
if err := gui.os.OpenLink(url); err != nil {
return fmt.Errorf(gui.Tr.FailedToOpenURL, url, err)
}
@@ -775,17 +789,18 @@ func NewGui(
test integrationTypes.IntegrationTest,
) (*Gui, error) {
gui := &Gui{
Common: cmn,
gitVersion: gitVersion,
Config: configurer,
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
Common: cmn,
gitVersion: gitVersion,
Config: configurer,
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
diffStatLinkWriterMap: map[string]*helpers.DiffStatLinkWriter{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.Stack[types.RepoLocation]{},
RepoStateMap: map[Repo]*GuiRepoState{},
GuiLog: []string{},
// initializing this to true for the time being; it will be reset to the
// real value after loading the user config:
+6
View File
@@ -74,6 +74,12 @@ func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Contex
// or a log, and reads as badly cut off at the edge of the pane as it would
// anywhere else.
view.Wrap = !mainContext.ContentIsDiff() || gui.c.UserConfig().Gui.WrapLinesInDiffView
// The files named in the diffstat are linked to where their diff begins, over a
// render that has both: the panel's own diff, and rows that can be placed in the
// files they show. The writer is told here, on the UI thread, since it is asked
// on the one reading the command's output.
gui.diffStatLinkWriter(view).BeginRender(
mainContext.ContentIsDiff() && gui.helpers.DiffLine.DiffRowsCanBePlaced())
}
if err := gui.runTaskForView(view, opts.Task); err != nil {
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/sirupsen/logrus"
)
@@ -150,12 +151,34 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
return nil
}
// contentWriter returns what a render of the given view writes its content to: the
// view itself, or, for a pane of the main section, the writer that links the files
// named in the diffstat on its way there (see DiffStatLinkWriter).
func (gui *Gui) contentWriter(view *gocui.View) io.Writer {
if gui.mainContextForView(view) == nil {
return view
}
return gui.diffStatLinkWriter(view)
}
// diffStatLinkWriter returns the writer that links the diffstat of the given pane,
// making it if the pane hasn't rendered yet. It lasts as long as the view does, and
// each render tells it what to make of that render (see DiffStatLinkWriter.BeginRender).
func (gui *Gui) diffStatLinkWriter(view *gocui.View) *helpers.DiffStatLinkWriter {
writer, ok := gui.diffStatLinkWriterMap[view.Name()]
if !ok {
writer = helpers.NewDiffStatLinkWriter(view)
gui.diffStatLinkWriterMap[view.Name()] = writer
}
return writer
}
func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
manager, ok := gui.viewBufferManagerMap[view.Name()]
if !ok {
manager = tasks.NewViewBufferManager(
gui.Log,
view,
gui.contentWriter(view),
func() {
// Called before showing the "loading..." indicator: clear the
// displayed buffer so only "loading..." is shown. The actual content
+10
View File
@@ -416,6 +416,11 @@ type TranslationSet struct {
NextHunk string
PrevFileInDiff string
NextFileInDiff string
JumpToFile string
JumpToFileInDiff string
JumpToFileInDiffTooltip string
OnlyOneFileInDiff string
NoFileInDiffNamed string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -1602,6 +1607,11 @@ func EnglishTranslationSet() *TranslationSet {
NextHunk: "Go to next hunk",
PrevFileInDiff: "Go to previous file",
NextFileInDiff: "Go to next file",
JumpToFile: "Jump to file",
JumpToFileInDiff: "Jump to file in diff",
JumpToFileInDiffTooltip: "Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel.",
OnlyOneFileInDiff: "There is only one file in this diff",
NoFileInDiffNamed: "This diff has no file named '{{.path}}'",
PrevConflict: "Previous conflict",
NextConflict: "Next conflict",
SelectPrevHunk: "Previous hunk",
@@ -0,0 +1,58 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickAFileInADiffStatThatComesFirst = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file by clicking its name in a diffstat the renderer's handshake runs into",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
// A renderer that announces the protocol and then passes the diff on as it came.
// The handshake has no newline after it, so it runs into the first line the
// renderer is given — which for the diff of a range of commits is the first
// entry of the diffstat, there being no commit above it.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `printf '\033]1717;1\007'; cat`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("aaa.txt", "one\n")
shell.Commit("one")
shell.CreateFileAndAdd("zzz.txt", "one\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("two")).
Press(keys.Universal.ToggleRangeSelect).
SelectNextItem().
SelectedLines(
Contains("two"),
Contains("one"),
)
// Below the line lazygit writes to say what the diff is of, the diff opens with
// the diffstat, and the handshake runs into its first entry. That is the one
// clicked here.
t.Views().Main().
TopLines(
Contains("Showing diff for range"),
Equals(""),
Contains("aaa.txt"),
Contains("zzz.txt"),
Contains("2 files changed"),
).
Click(2, 2)
t.Views().Commits().IsFocused()
t.Views().Main().
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt"))
},
})
@@ -0,0 +1,90 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickAFileInTheDiffStat = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file of a commit's diff by clicking the line that names it in the diffstat",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
// A long file at either end, so that the file jumped to is far below the
// diffstat and has a diff under it to scroll past.
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("one"))
// The click below is at a line of the diffstat, so the diff has to open with
// the lines this expects.
t.Views().Main().
TopLines(
Contains("commit"),
Contains("Author:"),
Contains("Date:"),
Equals(""),
Contains("one"),
Equals("---"),
Contains("aaa.txt"),
Contains("dir/bbb.txt"),
Contains("zzz.txt"),
Contains("3 files changed"),
).
Click(2, 8)
// The panel keeps the focus, and the diff goes to the file clicked.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one"))
t.Views().Main().
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
t.Views().Commits().
IsFocused().
Press(keys.Universal.FocusMainView)
// With the pane focused there is a selection to move, and the click moves it to
// the file, exactly as picking the file from the menu would.
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.GotoTop)
// The first file's diff begins on screen already, so the view stays where it
// is; a jump only scrolls as far as it must once there is a selection to point
// at the file with.
t.Views().Main().
TopVisibleLine(Contains("commit")).
Click(2, 6).
SelectedLines(
Contains("diff --git a/aaa.txt b/aaa.txt"),
).
TopVisibleLine(Contains("commit")).
// The last file's is far below, so that one is scrolled to.
Click(2, 8).
SelectedLines(
Contains("diff --git a/zzz.txt b/zzz.txt"),
).
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
},
})
@@ -0,0 +1,155 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var JumpToAFileOfTheDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jump to a file of a commit's diff by picking it from a menu of the diff's files",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the first file's diff is longer than the part of the
// diff that has been read when the menu asks which files there are.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
shell.CreateFileAndAdd("aaa.txt", strings.Join(lines, "\n")+"\n")
shell.CreateFileAndAdd("ccc.txt", "one\n")
shell.CreateFileAndAdd("dir/bbb.txt", "one\n")
// Another long one at the end, so that the file jumped to below has a diff
// under it to scroll past and ends up at the top of the view.
shell.CreateFileAndAdd("zzz.txt", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
shell.UpdateFileAndAdd("ccc.txt", "two\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A menu offering the one file of a single-file diff would be a menu with
// nothing to choose, so it says what it found instead.
t.Views().Commits().
Focus().
SelectedLine(Contains("two")).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.JumpToFile)
t.ExpectToast(Contains("There is only one file in this diff"))
t.Views().Main().
IsFocused().
Press(keys.Universal.Return)
t.Views().Commits().
IsFocused().
SelectNextItem().
SelectedLine(Contains("one")).
Press(keys.Universal.FocusMainView)
// Every file of the diff is offered, in the order the diff shows them and by
// the path the repo knows them by, including the ones below the part of the
// diff that has been read.
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.JumpToFile)
t.ExpectPopup().Menu().
Title(Equals("Jump to file")).
Lines(
Equals("aaa.txt"),
Equals("ccc.txt"),
Equals("dir/bbb.txt"),
Equals("zzz.txt"),
Equals("Cancel"),
).
Select(Equals("dir/bbb.txt")).
Confirm()
// The file lands where stepping to it with next-file would leave it: selected,
// and at the top of the view.
t.Views().Main().
IsFocused().
TopVisibleLine(Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt")).
SelectedLines(
Contains("diff --git a/dir/bbb.txt b/dir/bbb.txt"),
).
Press(keys.Universal.JumpToFile)
// The menu filters as you type, which is the point of it for a diff of many
// files.
t.ExpectPopup().Menu().
Title(Equals("Jump to file")).
Filter("ccc").
Lines(
Equals("ccc.txt"),
).
Confirm()
t.Views().Main().
IsFocused().
SelectedLines(
Contains("diff --git a/ccc.txt b/ccc.txt"),
).
Press(keys.Universal.Return)
// The panel the diff belongs to offers the same menu, so that a file can be
// jumped to without focusing the diff and having to come back out of it.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one")).
Press(keys.Universal.JumpToFile)
t.ExpectPopup().Menu().
Title(Equals("Jump to file in diff")).
Select(Equals("zzz.txt")).
Confirm()
// Only the diff moves: it goes to the top of the file, while the panel keeps
// the focus and its selection.
t.Views().Commits().
IsFocused().
SelectedLine(Contains("one"))
t.Views().Main().
TopVisibleLine(Contains("diff --git a/zzz.txt b/zzz.txt"))
// A panel showing something else in the main view — a branch's commit log —
// doesn't offer the menu at all, rather than offering it disabled.
t.Views().Branches().
Focus().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(DoesNotContain("Jump to file"))
}).
Cancel()
t.Views().Commits().
Focus().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(Contains("Jump to file in diff"))
}).
Cancel()
},
})
@@ -0,0 +1,74 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var JumpToAFileOnlyOverADiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The menu of the diff's files is offered only while the main view is showing a diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shared.CreateMergeConflictFiles(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit shows its diff in the main view, so the menu is offered over it.
t.Views().Commits().
Focus().
NavigateToLine(Contains("original")).
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(Contains("Jump to file in diff"))
}).
Cancel()
// The pane binds the same key itself, so the global one isn't offered on top of
// the pane's while the pane has the focus.
t.Views().Commits().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().
Content(Contains("Jump to file")).
Content(DoesNotContain("Jump to file in diff"))
}).
Cancel()
t.Views().Main().
IsFocused().
Press(keys.Universal.Return)
// Working through a conflicted file gives the main section over to the merge
// conflicts view, which is no diff to jump around in. The pane behind it goes
// on holding the diff it last rendered, so it is the view on screen that
// decides.
t.Views().Files().
Focus().
NavigateToLine(Contains("UU file1")).
PressEnter()
t.Views().MergeConflicts().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().Content(DoesNotContain("Jump to file"))
}).
Cancel()
},
})
@@ -0,0 +1,54 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoDiffStatLinksUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The files named in the diffstat are not linked under a diff renderer that says nothing about its rows",
ExtraCmdArgs: []string{},
Skip: false,
Width: 100,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
// An external diff whose output has nothing to say about which line of which
// file each row shows. git writes the diffstat itself, so the names are there
// to be clicked, but nothing could find the file they name.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Name: "opaque", Type: "extDiff", Command: `sh -c 'echo EXT'`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("aaa.txt", "one\n")
shell.CreateFileAndAdd("zzz.txt", "one\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("one"))
// The click below is at a line of the diffstat, so the diff has to open with
// the lines this expects.
t.Views().Main().
TopLines(
Contains("commit"),
Contains("Author:"),
Contains("Date:"),
Equals(""),
Contains("one"),
Equals("---"),
Contains("aaa.txt"),
Contains("zzz.txt"),
Contains("2 files changed"),
).
Click(2, 7)
// The name is no link, so the click is an ordinary one, which focuses the pane
// it lands in. Were it a link, it would have been followed instead, and would
// have had to report that it found no such file — the test fails on the toast
// that leaves unacknowledged.
t.Views().Main().IsFocused()
},
})
+5
View File
@@ -377,6 +377,8 @@ var tests = []*components.IntegrationTest{
main_view.BuildPatchWithMixedSelections,
main_view.ChangeContextSizeWhileBuildingPatch,
main_view.ChangeScreenModeInFocusedDiff,
main_view.ClickAFileInADiffStatThatComesFirst,
main_view.ClickAFileInTheDiffStat,
main_view.ClickSelectsDiffLine,
main_view.CommitFromMainView,
main_view.CopyRowsThatAreNoDiffLine,
@@ -404,6 +406,8 @@ var tests = []*components.IntegrationTest{
main_view.FocusLeavesAnAlwaysSplitEmptyPane,
main_view.FocusReturnsWhenSplitCollapses,
main_view.HideSelectionWhenChangesVanish,
main_view.JumpToAFileOfTheDiff,
main_view.JumpToAFileOnlyOverADiff,
main_view.KeepAWrappedLineCoveredAcrossARerender,
main_view.KeepBothHalvesOfAChangeSelected,
main_view.KeepPositionByTheVisibleEndOfASelection,
@@ -438,6 +442,7 @@ 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,
+14
View File
@@ -3128,6 +3128,20 @@
],
"default": "0"
},
"jumpToFile": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"default": "\u003cctrl+g\u003e"
},
"nextMatch": {
"oneOf": [
{