diff --git a/docs-master/Config.md b/docs-master/Config.md index d35455c84..c8bb8c5f6 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -145,6 +145,8 @@ gui: # If true, wrap lines in focused diffs to the width of the view. This makes it # much easier to work with diffs that have long lines, e.g. paragraphs of # markdown text. + # If false, scroll a focused diff sideways with the scrollLeft and scrollRight + # keys (H and L by default). wrapLinesInDiffView: true # If true, hunk selection mode will be enabled by default when focusing a diff. diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index eedde41e0..140defcf3 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -123,6 +123,7 @@ type GuiConfig struct { // - 'top': split the window vertically (side panel on top, main view below) EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"` // If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text. + // If false, scroll a focused diff sideways with the scrollLeft and scrollRight keys (H and L by default). WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"` // If true, hunk selection mode will be enabled by default when focusing a diff. UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"` diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f48a4704e..fb22ca13c 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -534,9 +534,14 @@ func (v *View) SelectSearchResult(index int) { index = itemCount - 1 } - y := v.searcher.searchPositions[index].Y + pos := v.searcher.searchPositions[index] + y := pos.Y v.FocusPoint(v.ox, y, true) + // A match without a position in the line has nothing to scroll to. + if pos.XStart >= 0 { + v.scrollColumnsIntoView(pos.XStart, pos.XEnd) + } v.renderSearchStatus(index, itemCount) if v.searcher.onSelectItem != nil { v.searcher.onSelectItem(v, y) @@ -1878,7 +1883,7 @@ func (v *View) draw(isWindowFocused bool) { var c cell for x < maxX { - if x < 0 && cellIdx >= len(vline.line) { + if x < gutterWidth && cellIdx >= len(vline.line) { // no more characters to write so we're only going to be printing empty cells // past this point x = gutterWidth @@ -1891,8 +1896,9 @@ func (v *View) draw(isWindowFocused bool) { c = vline.line[cellIdx] } - // Skip the cells scrolled out to the left. - if x < 0 && x+c.width <= 0 { + // Skip the cells scrolled past the left edge of the content area; the + // content area starts after the gutter. + if x < gutterWidth && x+c.width <= gutterWidth { x += c.width cellIdx++ continue @@ -1901,10 +1907,10 @@ func (v *View) draw(isWindowFocused bool) { // A terminal can't show half of a double-width character, so one that is cut // off by the left or right edge shows as a blank in the column of it that is // on screen. - if x < 0 || x+c.width > maxX { + if x < gutterWidth || x+c.width > maxX { c.chr = " " c.width = 1 - x = max(x, 0) + x = max(x, gutterWidth) } fgColor := c.fgColor @@ -2692,6 +2698,60 @@ func (v *View) ScrollRight(amount int) { v.clearHover() } +// scrollColumnsIntoView scrolls the view sideways if the columns from xStart up to +// xEnd aren't all on screen. If they fit in the view without any scrolling, the view +// goes back to the left edge; otherwise xStart ends up a third of the way across, so +// that some of what comes before it is on screen too. Only a view that doesn't wrap +// has columns off screen. +func (v *View) scrollColumnsIntoView(xStart, xEnd int) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + width := v.InnerWidth() - v.inclusionGutterWidth() + if xStart >= v.ox && xEnd <= v.ox+width { + return + } + + if xEnd <= width { + v.SetOriginX(0) + } else { + v.SetOriginX(xStart - width/3) + } + + v.clearHover() +} + +// ClampOriginXToContent scrolls the view back to the left if it is scrolled further +// than its content needs, so that the end of the widest line is at the right edge. +// A view that doesn't wrap keeps its horizontal scroll position when its content +// changes, and the new content may not reach as far. +func (v *View) ClampOriginXToContent() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if v.ox == 0 { + return + } + + v.refreshViewLinesIfNeeded() + widest := 0 + for _, vline := range v.viewLines { + width := 0 + for _, c := range vline.line { + width += c.width + } + widest = max(widest, width) + } + + contentWidth := v.InnerWidth() - v.inclusionGutterWidth() + newOx := min(v.ox, max(0, widest-contentWidth)) + if newOx != v.ox { + v.SetOriginX(newOx) + + v.clearHover() + } +} + func (v *View) adjustDownwardScrollAmount(scrollHeight int) int { _, oy := v.Origin() y := oy diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index edc800990..bd0aabd40 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1326,6 +1326,93 @@ func TestClickHyperlinkBesideTheInclusionGutter(t *testing.T) { opened) } +// Content scrolled to the right goes under the gutter, not over it: the gutter +// stays where it is, and the content shows from the scroll position onwards. +func TestInclusionGutterWithHorizontallyScrolledContent(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10 + v.InclusionGutterMarker = "✓" + + v.writeString("0123456789abcdef\n0123456789abcdef\n") + v.SetInclusionGutter(true, []bool{true, false}) + v.SetOriginX(3) + v.draw(true) + + row := func(y int) string { + s := "" + for x := 1; x <= 10; x++ { + chr, _, _ := Screen.Get(x, y) + s += chr + } + return s + } + assert.Equal(t, "✓ 3456789a", row(1)) + assert.Equal(t, " 3456789a", row(2)) +} + +// Selecting a search match that is off screen to the side scrolls the view so that +// the match starts a third of the way across it, or back to the left edge if that +// is enough to show the match. A match on screen leaves the view where it is. +func TestSelectSearchResultScrollsTheMatchIntoView(t *testing.T) { + v := NewView("name", 0, 0, 13, 5, OutputNormal) // InnerWidth 12 + + v.writeString("0123456789abcdefghijklmnopqrstuvwxyz\n") + + v.UpdateSearchResults("uv", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "u is at 30, and a third of the width is 4") + + v.UpdateSearchResults("yz", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "the match is on screen") + + v.UpdateSearchResults("mn", nil) + v.SelectSearchResult(0) + assert.Equal(t, 18, v.OriginX()) + + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 0, v.OriginX(), "the match fits without scrolling") + + // The gutter leaves the content ten columns, of which a third is 3. + v.InclusionGutterMarker = "✓" + v.SetInclusionGutter(true, []bool{false}) + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 7, v.OriginX(), "a is at 10, and doesn't fit in the first ten columns") +} + +// A view scrolled further right than its content needs goes back as far as it takes +// to put the end of the widest line at the right edge, and no further. +func TestClampOriginXToContent(t *testing.T) { + v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10 + v.InclusionGutterMarker = "✓" + + // The widest line is 16 wide. + v.writeString("0123456789abcdef\nshort\n") + + v.SetOriginX(4) + v.ClampOriginXToContent() + assert.Equal(t, 4, v.OriginX(), "the widest line still reaches the right edge") + + v.SetOriginX(10) + v.ClampOriginXToContent() + assert.Equal(t, 6, v.OriginX()) + + // The gutter leaves the content eight columns. + v.SetInclusionGutter(true, []bool{false, false}) + v.SetOriginX(10) + v.ClampOriginXToContent() + assert.Equal(t, 8, v.OriginX()) + + // Content that fits needs no scrolling at all. + v.Clear() + v.writeString("short\n") + v.ClampOriginXToContent() + assert.Equal(t, 0, v.OriginX()) +} + // A marked line the view wraps is marked on every segment it is drawn as, so that // the mark doesn't look like it belongs to the first part of the line alone. The // gutter takes its columns out of the width the content wraps in. diff --git a/pkg/gui/controllers/helpers/diff_line_restore.go b/pkg/gui/controllers/helpers/diff_line_restore.go index c51bbe31b..5cd924cc9 100644 --- a/pkg/gui/controllers/helpers/diff_line_restore.go +++ b/pkg/gui/controllers/helpers/diff_line_restore.go @@ -81,7 +81,7 @@ func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) { // Put the line back on the screen row it was on, clamped into the view for // the fallback lines, which can come from off screen. row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1)) - view.SetOrigin(0, max(0, viewLine-row)) + view.SetOriginY(max(0, viewLine-row)) if showSelection { // Put the far end back before the cursor, so that the selection covers // the same lines again; a selection whose far end didn't survive the diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go index e80147a1e..4d6b26b51 100644 --- a/pkg/gui/controllers/helpers/diff_line_selection.go +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -75,6 +75,10 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext, // 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. +// +// A jump that aligns its target to the top also scrolls the view back to the left +// edge, whether or not the target is on screen already. Such a jump goes to a file, and +// the user goes there to read the file from its start. func (self *DiffLineHelper) PlaceNavigationTarget( pane types.DiffPaneContext, target int, alignTop bool, ) { @@ -84,6 +88,7 @@ func (self *DiffLineHelper) PlaceNavigationTarget( return } if alignTop { + view.SetOriginX(0) self.scrollTargetToTop(pane, target) } // Jumping to another block or file moves the cursor without shift held, so a diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 7129e1c43..9135f363f 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -238,6 +238,8 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.handleScrollLeft, Description: self.c.Tr.ScrollLeft}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.handleScrollRight, Description: self.c.Tr.ScrollRight}, } } @@ -1052,6 +1054,16 @@ func (self *MainViewController) handleGotoBottom() error { return nil } +func (self *MainViewController) handleScrollLeft() error { + self.context.GetViewTrait().ScrollLeft() + return nil +} + +func (self *MainViewController) handleScrollRight() error { + self.context.GetViewTrait().ScrollRight() + return nil +} + func (self *MainViewController) editLine() error { view := self.context.GetView() if !view.Highlight { diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index c2db5f1ec..114c27705 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -233,8 +233,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { if linesHeight < originY { newOriginY := linesHeight - view.SetOrigin(0, newOriginY) + view.SetOriginY(newOriginY) } + view.ClampOriginXToContent() gui.updateDiffPaneDecorations(view, true) gui.clampDiffSelectionToContent(view) diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index b5168487a..d1b65a0d9 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -568,6 +568,15 @@ func (self *ViewDriver) OriginY(expected int) *ViewDriver { return self } +func (self *ViewDriver) OriginX(expected int) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().OriginX() + return expected == actual, fmt.Sprintf("%s: Expected origin X to be %d, got %d", self.context, expected, actual) + }) + + return self +} + // asserts that the selected line is inside the visible area of the view func (self *ViewDriver) SelectedLineIsVisible() *ViewDriver { self.t.assertWithRetries(func() (bool, string) { diff --git a/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go b/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go new file mode 100644 index 000000000..507668d73 --- /dev/null +++ b/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go @@ -0,0 +1,64 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var JumpToAnotherFileScrollsToTheLeftEdge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Jumping to another file of a diff scrolled sideways shows it from the left edge, while jumping to another hunk doesn't", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + long := strings.Repeat("word ", 60) + 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 "+long+"\nfour\nfive\nsix\nseven\neight\nnine "+long+"\nten\n") + shell.UpdateFileAndAdd("file2", "one\ntwo\nthree "+long+"\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"), + ). + Press(keys.Universal.ScrollRight). + OriginX(26). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-nine"), + ). + OriginX(26). + Press(keys.Main.NextFile). + SelectedLines( + Contains("diff --git a/file2 b/file2"), + ). + OriginX(0). + Press(keys.Universal.ScrollRight). + OriginX(26). + Press(keys.Main.PrevFile). + SelectedLines( + Contains("diff --git a/file1 b/file1"), + ). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go b/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go new file mode 100644 index 000000000..57c301ddc --- /dev/null +++ b/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go @@ -0,0 +1,53 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ScrollBackWhenTheDiffGetsNarrower = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff scrolled sideways scrolls back when it is rendered again narrower than where it was scrolled to", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("one") + + shell.UpdateFile("file1", "ONE\ntwo\nthree\nfour\nfive "+strings.Repeat("word ", 60)+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + SelectedLines( + Contains("-five"), + ). + Press(keys.Universal.RangeSelectDown). + SelectedLines( + Contains("-five"), + Contains("+five word"), + ). + Press(keys.Universal.ScrollRight). + Press(keys.Universal.ScrollRight). + OriginX(52). + // What is left of the diff fits in the pane, so the pane goes back to the left + // edge. + PressPrimaryAction(). + SelectedLines( + Contains("+ONE"), + ). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/main_view/scroll_diff_horizontally.go b/pkg/integration/tests/main_view/scroll_diff_horizontally.go new file mode 100644 index 000000000..ba9f29a5b --- /dev/null +++ b/pkg/integration/tests/main_view/scroll_diff_horizontally.go @@ -0,0 +1,75 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ScrollDiffHorizontally = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With wrapping turned off for diffs, the focused diff scrolls sideways, and stays scrolled as the selection moves and the diff is rendered again", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\n") + shell.Commit("one") + + long := strings.Repeat("word ", 60) + shell.UpdateFile("file1", "one "+long+"\ntwo "+long+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-one"), + ). + OriginX(0). + // A third of the pane's width at a time. + Press(keys.Universal.ScrollRight). + OriginX(26). + Press(keys.Universal.ScrollLeft). + OriginX(0). + Press(keys.Universal.ScrollLeft). + OriginX(0). + Press(keys.Universal.ScrollRight). + Press(keys.Universal.ScrollRight). + OriginX(52). + SelectNextItem(). + SelectedLines( + Contains("-two"), + ). + OriginX(52). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-two"), + ). + OriginX(52). + PressPrimaryAction(). + SelectedLines( + Contains("+one"), + ). + OriginX(52). + // Leaving the view gives up the horizontal scroll position. + PressEscape() + + t.Views().Files(). + IsFocused() + + t.Views().Main(). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go new file mode 100644 index 000000000..c9a2e52f0 --- /dev/null +++ b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchScrollsToAMatchOffTheEdge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With wrapping turned off for diffs, searching the focused diff scrolls it sideways to show a match past its edge", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one "+strings.Repeat("word ", 60)+"needle\ntwo needle\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + FilterOrSearch("needle"). + SelectedLines( + Contains("+one word"), + ). + // The match is at column 305 of the diff; it ends up a third of the way + // across the pane, which is 78 columns wide. + OriginX(279). + Press(keys.Universal.NextMatch). + SelectedLines( + Contains("+two needle"), + ). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 566baf0dc..496789e0c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -412,6 +412,7 @@ var tests = []*components.IntegrationTest{ main_view.JumpToAFileOfAStashWithNoPrefixes, main_view.JumpToAFileOfTheDiff, main_view.JumpToAFileOnlyOverADiff, + main_view.JumpToAnotherFileScrollsToTheLeftEdge, main_view.KeepAWrappedLineCoveredAcrossARerender, main_view.KeepAnEmptiedPaneEmpty, main_view.KeepBothHalvesOfAChangeSelected, @@ -470,8 +471,11 @@ var tests = []*components.IntegrationTest{ main_view.ResetAPatchBuiltFromACommitsDiff, main_view.ResetPatchWithEscape, main_view.ResetThePatchFromThePaneShowingIt, + main_view.ScrollBackWhenTheDiffGetsNarrower, + main_view.ScrollDiffHorizontally, main_view.SearchCollapsesTheSelection, main_view.SearchFollowsTheSelection, + main_view.SearchScrollsToAMatchOffTheEdge, main_view.SelectBelowALongCommitMessage, main_view.SelectBelowALongDiffstat, main_view.SelectDiffLines, diff --git a/schema-master/config.json b/schema-master/config.json index 2607715b2..869b8fb60 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -671,7 +671,7 @@ }, "wrapLinesInDiffView": { "type": "boolean", - "description": "If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.", + "description": "If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.\nIf false, scroll a focused diff sideways with the scrollLeft and scrollRight keys (H and L by default).", "default": true }, "useHunkModeInDiffView": {