From 89b2697936ce8b35a605efd2a26f9328680002af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 13 Sep 2026 13:48:07 +0200 Subject: [PATCH 1/4] 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) --- .../controllers/helpers/diff_line_queries.go | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index 8409b093c..9f3a184ee 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -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,14 +345,19 @@ 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. +// 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 From 4fe993442c3ba438810eada37e0727fe905c7e7f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 13 Sep 2026 13:49:16 +0200 Subject: [PATCH 2/4] 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) --- .../controllers/helpers/diff_line_queries.go | 71 +++++++++++-------- .../helpers/diff_line_queries_test.go | 35 +++++++++ 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index 9f3a184ee..b03a98739 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -363,49 +363,58 @@ func (self *DiffLineHelper) filePaths(view *gocui.View) []string { 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 diff --git a/pkg/gui/controllers/helpers/diff_line_queries_test.go b/pkg/gui/controllers/helpers/diff_line_queries_test.go index 47769ad98..25d7a5433 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries_test.go +++ b/pkg/gui/controllers/helpers/diff_line_queries_test.go @@ -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. From 0e0236ad7ba5bcfe155938404a101f3a0ba1c64c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 18 Sep 2026 10:22:50 +0200 Subject: [PATCH 3/4] Move where a jump lands into the diff line helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../helpers/diff_line_selection.go | 66 ++++++++++++++++++- pkg/gui/controllers/main_view_controller.go | 56 +--------------- 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go index 4b53d6613..e80147a1e 100644 --- a/pkg/gui/controllers/helpers/diff_line_selection.go +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -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 diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index f6528c27d..08aba2a00 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -842,50 +842,8 @@ 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) -} - -// 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))) + self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop) } // moveCursor moves the selection cursor by delta view lines (negative = up), with the @@ -917,17 +875,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 From e2eab7c956c5001ea2f4b65386df15113655f101 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 13 Sep 2026 13:54:13 +0200 Subject: [PATCH 4/4] 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) --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 3 + docs-master/keybindings/Keybindings_ja.md | 3 + docs-master/keybindings/Keybindings_ko.md | 3 + docs-master/keybindings/Keybindings_nl.md | 3 + docs-master/keybindings/Keybindings_pl.md | 3 + docs-master/keybindings/Keybindings_pt.md | 3 + docs-master/keybindings/Keybindings_ru.md | 3 + docs-master/keybindings/Keybindings_zh-CN.md | 3 + docs-master/keybindings/Keybindings_zh-TW.md | 3 + pkg/config/user_config.go | 2 + pkg/gui/controllers/global_controller.go | 61 +++++++ .../helpers/diff_line_file_menu.go | 70 ++++++++ .../helpers/diff_line_plain_text.go | 16 +- .../controllers/helpers/diff_line_queries.go | 22 +++ pkg/gui/controllers/main_view_controller.go | 13 ++ pkg/i18n/english.go | 8 + .../main_view/jump_to_a_file_of_the_diff.go | 155 ++++++++++++++++++ .../jump_to_a_file_only_over_a_diff.go | 74 +++++++++ pkg/integration/tests/test_list.go | 2 + schema-master/config.json | 14 ++ 21 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 pkg/gui/controllers/helpers/diff_line_file_menu.go create mode 100644 pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go create mode 100644 pkg/integration/tests/main_view/jump_to_a_file_only_over_a_diff.go diff --git a/docs-master/Config.md b/docs-master/Config.md index ee1c7ff43..d35455c84 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -709,6 +709,7 @@ keybinding: - "4" - "5" focusMainView: "0" + jumpToFile: nextMatch: "n" prevMatch: "N" startSearch: / diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 2aac3e182..992f34dc9 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -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. | +| `` `` | 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. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -234,6 +235,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Commit staged changes. | @@ -317,6 +319,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Commit staged changes. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index d273dbd2e..33f8a59b3 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -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. | +| `` `` | 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. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | @@ -203,6 +204,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 | | +| `` `` | 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. | | `` `` | サイドパネルに戻る | | | `` c `` | コミット | ステージされた変更をコミットします。 | @@ -294,6 +296,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 | | +| `` `` | 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. | | `` `` | サイドパネルに戻る | | | `` c `` | コミット | ステージされた変更をコミットします。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 4c28bfaa6..88a272d5b 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -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. | +| `` `` | 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. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -95,6 +96,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | @@ -189,6 +191,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 6fb5291a9..df0d2fa25 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -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. | +| `` `` | 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. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -242,6 +243,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | @@ -317,6 +319,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 46117e5fb..940a6181e 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -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. | +| `` `` | 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. | | `` `` | Anuluj | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | @@ -110,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | @@ -212,6 +214,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 704ccebf4..5ac753a74 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -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. | +| `` `` | 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. | | `` `` | Cancelar | | | `` ? `` | Abrir o menu de atalhos do teclado | | | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -246,6 +247,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Submeter mudanças em staging | @@ -326,6 +328,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Commit | Submeter mudanças em staging | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 33f7da705..9726e8012 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -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. | +| `` `` | 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. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -85,6 +86,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Сохранить изменения | Commit staged changes. | @@ -111,6 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | | `` n `` | Go to next file | | +| `` `` | 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. | | `` `` | Exit back to side panel | | | `` c `` | Сохранить изменения | Commit staged changes. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9b5afed6e..946b61812 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -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. | +| `` `` | 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. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | @@ -281,6 +282,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 | | +| `` `` | 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. | | `` `` | 退出回到侧边面板 | | | `` c `` | 提交变更 | 提交暂存文件 | @@ -323,6 +325,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 | | +| `` `` | 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. | | `` `` | 退出回到侧边面板 | | | `` c `` | 提交变更 | 提交暂存文件 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index bee7ce31f..af5625138 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -24,6 +24,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` _ `` | 上一個螢幕模式 | | | `` \| `` | 切換差異渲染器 | 選擇已設定的差異渲染器清單中的下一個渲染器。 | | `` \ `` | 切換差異渲染器(反向) | 選擇已設定的差異渲染器清單中的上一個渲染器。 | +| `` `` | 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. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | 檢視用於過濾提交日誌的選項,以便僅顯示與過濾器匹配的提交。 | @@ -70,6 +71,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 | | +| `` `` | 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. | | `` `` | 退出回到側邊面板 | | | `` c `` | 提交變更 | 提交暫存區變更 | @@ -345,6 +347,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 | | +| `` `` | 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. | | `` `` | 退出回到側邊面板 | | | `` c `` | 提交變更 | 提交暫存區變更 | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 20b6b39f7..eedde41e0 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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{""}, JumpToBlock: []Keybinding{{"1"}, {"2"}, {"3"}, {"4"}, {"5"}}, FocusMainView: Keybinding{"0"}, + JumpToFile: Keybinding{""}, NextMatch: Keybinding{"n"}, PrevMatch: Keybinding{"N"}, StartSearch: Keybinding{"/"}, diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index f4de89567..a51b70fc5 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -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() } diff --git a/pkg/gui/controllers/helpers/diff_line_file_menu.go b/pkg/gui/controllers/helpers/diff_line_file_menu.go new file mode 100644 index 000000000..a2e4c30bb --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_file_menu.go @@ -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, + }) +} diff --git a/pkg/gui/controllers/helpers/diff_line_plain_text.go b/pkg/gui/controllers/helpers/diff_line_plain_text.go index f76c4f0a7..6b53d94af 100644 --- a/pkg/gui/controllers/helpers/diff_line_plain_text.go +++ b/pkg/gui/controllers/helpers/diff_line_plain_text.go @@ -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) +} diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index b03a98739..ce6fba60a 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -345,6 +345,28 @@ func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, f return view.ViewLineForBufferLine(target) } +// FilesInDiff lists the files of view's (possibly multi-file) rendered diff, in the +// order it shows them, by the paths of the repo's files. It is what a menu offering to +// jump between them is built from; jumping to one of them goes to StartOfFileInDiff. +func (self *DiffLineHelper) FilesInDiff(view *gocui.View) []string { + return lo.Map(fileStarts(self.filePaths(view)), + func(start diffFileStart, _ int) string { return start.path }) +} + +// StartOfFileInDiff returns the view line the given file's section of view's rendered +// diff begins at. That is the row file navigation lands on, so jumping to a file from a +// menu and stepping to it with next-file land in the same place. ok is false for a file +// the diff doesn't show, e.g. because it was re-rendered since the file was listed. +func (self *DiffLineHelper) StartOfFileInDiff(view *gocui.View, path string) (int, bool) { + start, ok := lo.Find(fileStarts(self.filePaths(view)), func(start diffFileStart) bool { + return start.path == path + }) + if !ok { + return 0, false + } + return view.ViewLineForBufferLine(start.row) +} + // filePaths resolves view's rendered diff to the path each buffer line belongs to, in // the repo's terms, and empty for a row whose identity couldn't be recovered. Naming the // files the way the rest of the queries name them means a row of the custom patch's diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 08aba2a00..71aee5888 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -159,6 +159,15 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff), GetDisabledReason: self.diffSelectionDisabledReason, }, + { + Keys: opts.GetKeys(opts.Config.Universal.JumpToFile), + Handler: self.openJumpToFileMenu, + Description: self.c.Tr.JumpToFile, + DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.JumpToFile), + GetDisabledReason: self.diffSelectionDisabledReason, + OpensMenu: true, + DisplayOnScreen: true, + }, { Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), Handler: self.openPullRequestAtSelectedLine, @@ -846,6 +855,10 @@ func (self *MainViewController) placeNavigationTarget(target int, alignTop bool) self.c.Helpers().DiffLine.PlaceNavigationTarget(self.context, target, alignTop) } +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 // configured scroll-off margin, reading more content in first when moving down. The // range anchor is left untouched, so this extends or contracts a range and just moves diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index c6ada57f4..4aee3fb42 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -417,6 +417,10 @@ type TranslationSet struct { NextHunk string PrevFileInDiff string NextFileInDiff string + JumpToFile string + JumpToFileInDiff string + JumpToFileInDiffTooltip string + OnlyOneFileInDiff string PrevConflict string NextConflict string SelectPrevHunk string @@ -1604,6 +1608,10 @@ 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", PrevConflict: "Previous conflict", NextConflict: "Next conflict", SelectPrevHunk: "Previous hunk", diff --git a/pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go b/pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go new file mode 100644 index 000000000..d36959b16 --- /dev/null +++ b/pkg/integration/tests/main_view/jump_to_a_file_of_the_diff.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/jump_to_a_file_only_over_a_diff.go b/pkg/integration/tests/main_view/jump_to_a_file_only_over_a_diff.go new file mode 100644 index 000000000..73a72d889 --- /dev/null +++ b/pkg/integration/tests/main_view/jump_to_a_file_only_over_a_diff.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0e27c9bbd..dad72fd68 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -405,6 +405,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, diff --git a/schema-master/config.json b/schema-master/config.json index b9fe29991..2607715b2 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3138,6 +3138,20 @@ ], "default": "0" }, + "jumpToFile": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+g\u003e" + }, "nextMatch": { "oneOf": [ {