mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
Jump by hunk and by file in the focused main view
Reading a diff of any size means moving in bigger steps than a line at a time, which the staging view offers and the main view didn't. So the same hunk keys work here, and n / N step from file to file — worth having only here, since a diff spanning several files is something the staging view never showed. Where "the next file" begins isn't in the text: a diff renderer may print whatever it likes above a file's content. So navigation lands on the first row that states which file it belongs to, which is the file's header when the source says so and its first content line otherwise. The anchor's own file is found by scanning down rather than to the nearest row either way — having just landed on a header, the nearest row above belongs to the file we came from, and taking it would send the next press back where we started. A file you go to is brought to the top of the view, since the file is what you went there for and the more of it is on screen the better. That only applies where the view has to scroll at all: a file already on screen leaves it where it is. In hunk mode what ends up selected is the file's first change rather than the row the file begins at, and a large context size can put that change further down than a screenful; the selection is scrolled into view afterwards as any other jump's is, and the alignment gives way where the two can't both hold. The diff loads lazily, so a target below the loaded portion isn't there to be found; rather than doing nothing, we read the rest in and look again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c7a5276d11
commit
e583bded5d
@@ -672,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
|
||||
type KeybindingMainConfig struct {
|
||||
PrevHunk Keybinding `yaml:"prevHunk"`
|
||||
NextHunk Keybinding `yaml:"nextHunk"`
|
||||
PrevFile Keybinding `yaml:"prevFile"`
|
||||
NextFile Keybinding `yaml:"nextFile"`
|
||||
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
|
||||
PickBothHunks Keybinding `yaml:"pickBothHunks"`
|
||||
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
|
||||
@@ -1182,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Main: KeybindingMainConfig{
|
||||
PrevHunk: Keybinding{"<left>", "h"},
|
||||
NextHunk: Keybinding{"<right>", "l"},
|
||||
PrevFile: Keybinding{"N"},
|
||||
NextFile: Keybinding{"n"},
|
||||
ToggleSelectHunk: Keybinding{"a"},
|
||||
PickBothHunks: Keybinding{"b"},
|
||||
EditSelectHunk: Keybinding{"E"},
|
||||
|
||||
@@ -221,6 +221,105 @@ func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine
|
||||
return view.ViewLineForBufferLine(target)
|
||||
}
|
||||
|
||||
// AdjacentFile returns the view line to move to for next/previous file navigation in
|
||||
// view's (possibly multi-file) rendered diff, starting from anchorViewLine: the first
|
||||
// located row of the neighbouring file, found where the rows' file changes. ok is
|
||||
// false at the first or last file.
|
||||
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
||||
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
target, ok := fileStart(self.filePaths(view), anchor, forward)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
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.
|
||||
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
paths := make([]string, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok {
|
||||
paths[i] = row.info.Path
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
||||
// a row no backend could place), the first located row of the file adjacent to `from`
|
||||
// in the given direction — the row file navigation lands on. It is the pure index
|
||||
// arithmetic behind AdjacentFile.
|
||||
//
|
||||
// A file is identified by its path, so we look for where the path changes, skipping
|
||||
// rows that carry none: those are the blank separator rows between files, or the
|
||||
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
||||
// So the landing row is the file's header wherever the source says so — a parseable
|
||||
// buffer, or a renderer that tags its headers — and the file's first content line
|
||||
// otherwise, which is an accepted degradation.
|
||||
func fileStart(paths []string, from int, forward bool) (int, bool) {
|
||||
anchorPath, ok := anchorFilePath(paths, from)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if forward {
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" && paths[i] != anchorPath {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
|
||||
// below it that carries a path — the file whose content is at or below the top of the
|
||||
// view — falling back to the nearest above when there is nothing below. Scanning down
|
||||
// first matters because the anchor is often a file-header row that carries no path of
|
||||
// its own, whose nearest tagged row above is the *previous* file's content; taking
|
||||
// that would make next-file navigation jump back into the file just left, so a second
|
||||
// press wouldn't advance. ok is false when no row carries a path.
|
||||
func anchorFilePath(paths []string, from int) (string, bool) {
|
||||
if from < 0 {
|
||||
return "", false
|
||||
}
|
||||
for i := from; i < len(paths); i++ {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
for i := min(from, len(paths)) - 1; i >= 0; i-- {
|
||||
if paths[i] != "" {
|
||||
return paths[i], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
|
||||
// line of the change block adjacent to `from` in the given direction. It is the pure
|
||||
// index arithmetic behind AdjacentChangeBlock.
|
||||
|
||||
@@ -47,3 +47,58 @@ func TestChangeBlockStart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
|
||||
|
||||
// The same diff as a renderer that doesn't say which file its headers belong to
|
||||
// emits it: only content lines carry the path, so navigation can land no higher
|
||||
// than each file's first content line.
|
||||
contentOnly := []string{"", "", "a", "a", "", "", "b", "b"}
|
||||
|
||||
// Three such files, to exercise navigating from one file's untagged header to the
|
||||
// next: the row just above b's header is a's content, so the anchor's file has to
|
||||
// be found by scanning down (b) rather than up (a) — otherwise next-file would
|
||||
// jump back into b and a second press couldn't advance.
|
||||
contentOnlyThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
|
||||
|
||||
// A renderer that does tag its header rows: the file header and the hunk-header box
|
||||
// carry the file's path, but the blank separator rows around them carry nothing.
|
||||
// Navigation must land on the header's first row, not the blank line above it.
|
||||
// 0 blank 1-2 file hdr 3 blank 4-6 hunk hdr box 7 content
|
||||
// 8 blank 9-10 file hdr 11 blank 12-13 hunk hdr box 14 content
|
||||
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
paths []string
|
||||
from int
|
||||
forward bool
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{"forward lands on the next file's header", parseable, 1, true, 4, true},
|
||||
{"forward from the last file finds nothing", parseable, 5, true, 0, false},
|
||||
{"backward lands on the previous file's header", parseable, 5, false, 0, true},
|
||||
{"backward from the first file finds nothing", parseable, 1, false, 0, false},
|
||||
{"forward lands on the next file's first content line", contentOnly, 2, true, 6, true},
|
||||
{"backward lands on the previous file's first content line", contentOnly, 7, false, 2, true},
|
||||
{"forward from an untagged header advances past it", contentOnly, 0, true, 6, true},
|
||||
{"a second forward press advances again", contentOnlyThree, 4, true, 10, true},
|
||||
{"forward lands on a tagged file header", headerTagged, 7, true, 9, true},
|
||||
{"backward lands on a tagged file header", headerTagged, 14, false, 1, true},
|
||||
{"a diff with no located rows finds nothing", []string{"", ""}, 0, true, 0, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
got, found := fileStart(s.paths, s.from, s.forward)
|
||||
assert.Equal(t, s.found, found)
|
||||
if s.found {
|
||||
assert.Equal(t, s.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,34 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevHunk),
|
||||
Handler: self.prevChangeBlock,
|
||||
Description: self.c.Tr.PrevHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextHunk),
|
||||
Handler: self.nextChangeBlock,
|
||||
Description: self.c.Tr.NextHunk,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextHunk),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.PrevFile),
|
||||
Handler: self.prevFile,
|
||||
Description: self.c.Tr.PrevFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.PrevFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.NextFile),
|
||||
Handler: self.nextFile,
|
||||
Description: self.c.Tr.NextFileInDiff,
|
||||
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.NextFileInDiff),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
||||
Handler: self.escape,
|
||||
@@ -487,7 +515,9 @@ func selectDiffHunk(
|
||||
// line. With a selection we move it there and scroll it into view, re-selecting the
|
||||
// whole block in hunk mode; with none we stay in scroll mode, bringing the target
|
||||
// to the top without selecting anything.
|
||||
func (self *MainViewController) navigate(find findDiffRowFn, forward bool) {
|
||||
// alignTop says what a jump does with a target it has to scroll to: bring it to the
|
||||
// top of the view, or leave the scrolling to place it as it sees fit.
|
||||
func (self *MainViewController) navigate(find findDiffRowFn, forward bool, alignTop bool) {
|
||||
v := self.context.GetView()
|
||||
anchor := v.OriginY()
|
||||
if v.Highlight {
|
||||
@@ -495,7 +525,7 @@ func (self *MainViewController) navigate(find findDiffRowFn, forward bool) {
|
||||
}
|
||||
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target)
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
return
|
||||
}
|
||||
if !forward {
|
||||
@@ -513,7 +543,7 @@ func (self *MainViewController) navigate(find findDiffRowFn, forward bool) {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
if target, ok := find(v, anchor, forward); ok {
|
||||
self.placeNavigationTarget(target)
|
||||
self.placeNavigationTarget(target, alignTop)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -524,12 +554,52 @@ func (self *MainViewController) navigate(find findDiffRowFn, forward bool) {
|
||||
// the anchor view line to start from, and the direction.
|
||||
type findDiffRowFn func(view *gocui.View, anchorViewLine int, forward bool) (int, bool)
|
||||
|
||||
func (self *MainViewController) placeNavigationTarget(target int) {
|
||||
func (self *MainViewController) nextChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, true, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevChangeBlock() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, false, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextFile and prevFile bring the file they go to to the top of the view, since what
|
||||
// you are going there for is the file, and the more of it is on screen the better.
|
||||
func (self *MainViewController) nextFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, true, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) prevFile() error {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentFile, false, true)
|
||||
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
|
||||
@@ -539,6 +609,19 @@ func (self *MainViewController) placeNavigationTarget(target int) {
|
||||
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)))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -563,8 +646,19 @@ func (self *MainViewController) moveCursor(delta int) {
|
||||
// sticky range is kept, so the move extends it.
|
||||
func (self *MainViewController) collapseForLineMove() {
|
||||
sel := self.diffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeHunk ||
|
||||
(sel.Mode == types.DiffSelectModeRange && !sel.RangeIsSticky) {
|
||||
if sel.Mode == types.DiffSelectModeHunk {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
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()
|
||||
}
|
||||
@@ -580,7 +674,7 @@ func (self *MainViewController) adjustSelection(delta int) {
|
||||
return
|
||||
}
|
||||
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0)
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
|
||||
return
|
||||
}
|
||||
self.collapseForLineMove()
|
||||
|
||||
@@ -411,6 +411,8 @@ type TranslationSet struct {
|
||||
AskQuestion string
|
||||
PrevHunk string
|
||||
NextHunk string
|
||||
PrevFileInDiff string
|
||||
NextFileInDiff string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -1600,6 +1602,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AskQuestion: "Ask Question",
|
||||
PrevHunk: "Go to previous hunk",
|
||||
NextHunk: "Go to next hunk",
|
||||
PrevFileInDiff: "Go to previous file",
|
||||
NextFileInDiff: "Go to next file",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
|
||||
@@ -141,6 +141,24 @@ func (self *ViewDriver) VisibleLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
return self.assertLines(originY, matchers...)
|
||||
}
|
||||
|
||||
// Asserts on the line the view shows at the top of its viewport, i.e. on where the
|
||||
// view is scrolled to. It is a view line, so a wrapped line above it doesn't throw the
|
||||
// count off.
|
||||
func (self *ViewDriver) TopVisibleLine(matcher *TextMatcher) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
view := self.getView()
|
||||
lines := view.ViewBufferLines()
|
||||
originY := view.OriginY()
|
||||
if originY >= len(lines) {
|
||||
return false, fmt.Sprintf("%s: the view is scrolled to line %d, but it has only %d lines",
|
||||
self.context, originY, len(lines))
|
||||
}
|
||||
return matcher.context(fmt.Sprintf("%s top visible line", self.context)).test(lines[originY])
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that somewhere in the view there are consecutive lines matching the given matchers.
|
||||
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
self.validateMatchersPassed(matchers)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FileNavigationScrollsToTheTop = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Going to a file of a diff brings it to the top of the view, unless it is on screen already",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 20,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// More context than fits on screen, so that a long file's change is further
|
||||
// down than a screenful from the header naming the file.
|
||||
config.GetUserConfig().Git.DiffContextSize = 30
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
long := strings.Join(lines, "\n") + "\n"
|
||||
longChanged := strings.Replace(long, "line100", "LINE100", 1)
|
||||
|
||||
// Two long files with two short ones between them: the short ones are on screen
|
||||
// together, and there is enough diff below them to scroll to.
|
||||
shell.CreateFileAndAdd("aaa.txt", long)
|
||||
shell.CreateFileAndAdd("bbb.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ccc.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ddd.txt", long)
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("aaa.txt", longChanged)
|
||||
shell.UpdateFileAndAdd("bbb.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ccc.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ddd.txt", longChanged)
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// A file below the viewport becomes the top of it, so that as much of the file
|
||||
// as possible is on screen.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/bbb.txt b/bbb.txt"),
|
||||
).
|
||||
// The next file is on screen already, so the view stays where it is and
|
||||
// only the selection moves.
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/ccc.txt b/ccc.txt"),
|
||||
).
|
||||
// Going back to a file above the viewport brings that one to the top.
|
||||
Press(keys.Main.PrevFile).
|
||||
Press(keys.Main.PrevFile).
|
||||
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/aaa.txt b/aaa.txt"),
|
||||
).
|
||||
// In hunk mode the selection is the file's first change rather than the row
|
||||
// the file begins at, and with this much context that change is further down
|
||||
// than a screenful. The selection has to be on screen, so the alignment gives
|
||||
// way and the selection is scrolled into view as any other jump's is.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("-line100"),
|
||||
Contains("+LINE100"),
|
||||
).
|
||||
SelectedLineIsVisible()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var NavigateByHunkAndFile = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump from hunk to hunk and from file to file in the focused main view of a commit's diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
|
||||
shell.UpdateFileAndAdd("file2", "one\ntwo\nTHREE\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("two").IsSelected(),
|
||||
Contains("one"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// Hunk navigation moves between change blocks. A hunk, in lazygit's sense,
|
||||
// is a run of changes bounded by context; one @@ hunk may hold several.
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// File navigation lands on the top of the next file's diff, which for a
|
||||
// parseable diff is its header.
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.PrevFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file1 b/file1"),
|
||||
).
|
||||
// A range that only grows while shift is held is a plain selection again once
|
||||
// we jump elsewhere, rather than stretching to wherever we land.
|
||||
Press(keys.Main.NextHunk).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
// A sticky range does stretch to it.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
Contains(" four"),
|
||||
Contains(" five"),
|
||||
Contains(" six"),
|
||||
Contains(" seven"),
|
||||
Contains(" eight"),
|
||||
Contains("-nine"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -30,7 +30,9 @@ var SelectionCommandsOnlyWhereTheyApply = NewIntegrationTest(NewIntegrationTestA
|
||||
Tap(func() {
|
||||
t.Views().Menu().
|
||||
Content(DoesNotContain("Select hunks")).
|
||||
Content(DoesNotContain("Toggle range select"))
|
||||
Content(DoesNotContain("Toggle range select")).
|
||||
Content(DoesNotContain("Go to next hunk")).
|
||||
Content(DoesNotContain("Go to next file"))
|
||||
}).
|
||||
Cancel()
|
||||
|
||||
|
||||
@@ -367,8 +367,10 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.DragRangeWithAutoscroll,
|
||||
main_view.DragSelectsDiffLineRange,
|
||||
main_view.EscapeDismissesSelection,
|
||||
main_view.FileNavigationScrollsToTheTop,
|
||||
main_view.HideSelectionWhenChangesVanish,
|
||||
main_view.KeepSelectionVisibleWhenDiffShrinks,
|
||||
main_view.NavigateByHunkAndFile,
|
||||
main_view.NoSelectionOverABinaryDiff,
|
||||
main_view.NoSelectionOverACommitLog,
|
||||
main_view.NoSelectionWhenNoChanges,
|
||||
|
||||
Reference in New Issue
Block a user