Compare commits

...
Author SHA1 Message Date
Stefan HallerandClaude Opus 5 b516df91f6 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-10-05 11:20:34 +02:00
Stefan HallerandClaude Opus 5 ee3fc5d6dd 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-10-05 11:20:34 +02:00
Stefan HallerandClaude Opus 5 2ea058d138 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-10-05 11:20:34 +02:00
Stefan HallerandClaude Opus 5 aa379bdefb 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-10-05 11:20:34 +02:00
Stefan HallerandClaude Opus 5 8c3ce34e20 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-10-05 11:20:34 +02:00
Stefan HallerandClaude Opus 5 c5fadcd07e Open the selected diff line in the branch's pull request
Reading a change in lazygit and saying something about it on GitHub
means finding the line again in the browser: open the pull request, find
the commit, find the file, scroll to the line. The line is already under
the cursor here.

Bind G in the focused main view, the key the commits panel opens the
pull request with, to open it at the line the selection is on. The URL
names the commits whose diff is on screen, so that the line numbers of
the diff are the ones the page shows, the file by the SHA-256 of its
repo-relative path, and the line by the side of the diff it is on: R for
the new version of the file, L for the old one, where a deleted line is.
GitHub documents none of that; the form was read off the URLs its own
pages carry.

One commit is named by its hash. A range of them is named by the commit
the range starts after and the commit it ends at, the form the chooser
above a pull request's files uses. The commit a range starts after is
the parent of its oldest commit; where the range starts where the pull
request itself does, that parent is none of the pull request's own
commits, and the keyword BASE stands for it.

Which branch's pull request that is depends on the panel beneath. The
commits panel lists the commits of the checked-out branch, the
sub-commits panel those of the branch drilled into, and the commit files
panel shows the files of a commit from either. In a stack of branches,
each with a pull request of its own, those lists include the commits of
the branches below, and each of those commits is in the pull request of
its own branch. So the command looks upwards from the commit for the
nearest head of a branch with a pull request, and takes the listed
branch if it finds none. Panels showing a diff that no pull request has
a view of don't answer, and the command isn't offered over their diffs
at all.

Neither is it offered over a diff that is not the commit's own, where
the line numbers on screen are not the ones the page shows: a diff
against another ref in diffing mode, and the custom patch, whose lines
sit at the numbers the patch gives them.

A pull request holds only the commits of its branch that are pushed, and
its pages say they can't find any other commit. So the command refuses
where a commit of the diff is not one of the pull request's. Amend a
commit in the middle of the branch, and the diffs of the commits below
it still open; the ones above it sit on hashes the remote doesn't have.
A commit from before the branch, in a main branch already, is refused
too, and so is a range of commits that reaches across the head of a
branch in a stack, since its commits are in two pull requests. Whether a
commit is pushed is known only for the upstream of the listed branch.
For a branch lower in a stack, that is right as long as the branches of
the stack are pushed together.

Only GitHub pull requests are known, since that is where the pull
request data comes from. The whole path can't be exercised headlessly:
no pull request reaches the model without a GitHub token, so the test
covers where the command is offered and the three reasons it refuses.
The URL is unit-tested instead, both the anchor of a line and the way
the commits are named, and so is the choice of a branch in a stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:20:33 +02:00
Stefan HallerandClaude Opus 5 9f919a038b Ask for the panel beneath the focused main view in one place
Two questions the pane answers from the panel beneath it each reach for
it themselves, guard included. Opening a line in a pull request needs it
twice more, for the branch and for the commit.

Extract sidePanelBeneath, which is also where the guard against asking
for the panel beneath an off-stack pane now belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 11:20:33 +02:00
Stefan HallerandClaude Opus 5 dc131a07cb Name a diff line's file in the repo's terms in one place
A diff line carries the absolute path of its file, and both panels
acting on such a line turn it into the repo-relative one git speaks
themselves. Opening a line in a pull request needs that path too, to
name the file to GitHub by it.

Extract repoRelativePath, and have both panels use it. The files panel
gains the check for a path outside the repo that the other one had; a
path it used to pass on matches no file of the working tree either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 11:20:33 +02:00
Stefan HallerandClaude Opus 5 4cb8883f3c Ask one place whether a branch has a pull request
Two panels offer a branch's pull request today, and each looks it up in
the model's map itself and builds the same disabled reason from the same
string. The focused main view is about to offer a line of the diff in
that pull request, which would make three.

Put the lookup and the disabled reason on the host helper, beside the
pull request URL it already builds, and have both panels ask it.

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