From c5fadcd07e4ca77fd377087a78207f2e31b8d34a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 13 Sep 2026 13:42:37 +0200 Subject: [PATCH] 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) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/keybindings/Keybindings_en.md | 2 + docs-master/keybindings/Keybindings_ja.md | 2 + docs-master/keybindings/Keybindings_ko.md | 2 + docs-master/keybindings/Keybindings_nl.md | 2 + docs-master/keybindings/Keybindings_pl.md | 2 + docs-master/keybindings/Keybindings_pt.md | 2 + docs-master/keybindings/Keybindings_ru.md | 2 + docs-master/keybindings/Keybindings_zh-CN.md | 2 + docs-master/keybindings/Keybindings_zh-TW.md | 2 + pkg/gui/context/commit_files_context.go | 19 +- pkg/gui/context/local_commits_context.go | 121 +++++++++++- pkg/gui/context/local_commits_context_test.go | 185 ++++++++++++++++++ pkg/gui/context/sub_commits_context.go | 26 ++- pkg/gui/controllers/main_view_controller.go | 159 +++++++++++++++ .../controllers/main_view_controller_test.go | 115 +++++++++++ pkg/gui/types/context.go | 40 ++++ pkg/i18n/english.go | 14 ++ ...n_pull_request_only_over_a_commits_diff.go | 105 ++++++++++ pkg/integration/tests/test_list.go | 1 + 19 files changed, 791 insertions(+), 12 deletions(-) create mode 100644 pkg/gui/controllers/main_view_controller_test.go create mode 100644 pkg/integration/tests/main_view/open_pull_request_only_over_a_commits_diff.go diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 0277cb653..2aac3e182 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | @@ -316,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index dc4d085ee..d273dbd2e 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -203,6 +203,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | サイドパネルに戻る | | | `` c `` | コミット | ステージされた変更をコミットします。 | | `` w `` | pre-commitフックなしで変更をコミット | | @@ -293,6 +294,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | サイドパネルに戻る | | | `` c `` | コミット | ステージされた変更をコミットします。 | | `` w `` | pre-commitフックなしで変更をコミット | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index e87332ed2..4c28bfaa6 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -95,6 +95,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | | `` w `` | Commit changes without pre-commit hook | | @@ -188,6 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | | `` w `` | Commit changes without pre-commit hook | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index d130c9531..6fb5291a9 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -242,6 +242,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | @@ -316,6 +317,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 3c191bc8a..46117e5fb 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -110,6 +110,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | @@ -211,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 2723f1189..704ccebf4 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -246,6 +246,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | @@ -325,6 +326,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 591b55c3d..33f7da705 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -85,6 +85,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | @@ -110,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | Exit back to side panel | | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index f51a4f8c0..9b5afed6e 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -281,6 +281,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | 退出回到侧边面板 | | | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | @@ -322,6 +323,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | 退出回到侧边面板 | | | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index e04968bef..bee7ce31f 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -70,6 +70,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | 退出回到側邊面板 | | | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | @@ -344,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | | `` n `` | Go to next 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. | | `` `` | 退出回到側邊面板 | | | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index 4ad7334ef..7cce0dd69 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -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 }, diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 4c489a706..0f09c859c 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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( diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go index f93af3a72..baecf62cf 100644 --- a/pkg/gui/context/local_commits_context_test.go +++ b/pkg/gui/context/local_commits_context_test.go @@ -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)) + }) + } +} diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index d0211bb1a..c5d70d0fe 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -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 { diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 6511562eb..f6528c27d 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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,14 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff), GetDisabledReason: self.diffSelectionDisabledReason, }, + { + 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, @@ -440,6 +453,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 @@ -534,6 +560,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 @@ -1051,6 +1121,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() { diff --git a/pkg/gui/controllers/main_view_controller_test.go b/pkg/gui/controllers/main_view_controller_test.go new file mode 100644 index 000000000..d7ec54f34 --- /dev/null +++ b/pkg/gui/controllers/main_view_controller_test.go @@ -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)) + }) + } +} diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 1dd5cc33c..958ebf759 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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. diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 170d1abb1..c6ada57f4 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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 @@ -1467,6 +1474,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 +1494,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.", diff --git a/pkg/integration/tests/main_view/open_pull_request_only_over_a_commits_diff.go b/pkg/integration/tests/main_view/open_pull_request_only_over_a_commits_diff.go new file mode 100644 index 000000000..c4d9c11f2 --- /dev/null +++ b/pkg/integration/tests/main_view/open_pull_request_only_over_a_commits_diff.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 55a647aa1..0e27c9bbd 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -444,6 +444,7 @@ var tests = []*components.IntegrationTest{ main_view.NoSelectionOverACommitLog, main_view.NoSelectionOverAConflictHint, main_view.NoSelectionWhenNoChanges, + main_view.OpenPullRequestOnlyOverACommitsDiff, main_view.PatchMarksFollowARendererSwitch, main_view.PatchMarksShowWheneverTheirDiffIsOnScreen, main_view.RangeSelectDiffLines,