diff --git a/docs-master/Config.md b/docs-master/Config.md index c32a813dd..a263867a4 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -845,6 +845,8 @@ keybinding: main: prevHunk: [, h] nextHunk: [, l] + prevFile: "N" + nextFile: "n" toggleSelectHunk: a pickBothHunks: b editSelectHunk: E diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index b5956295e..a20005ba4 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -225,6 +225,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Toggle range select | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | @@ -332,6 +336,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Toggle range select | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index b6c7bd034..9c051f152 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -194,6 +194,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 範囲選択を切り替え | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | @@ -309,6 +313,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 範囲選択を切り替え | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 5768e496c..d3c83bd91 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -86,6 +86,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 드래그 선택 전환 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | @@ -166,6 +170,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | 드래그 선택 전환 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 2b37042c5..a997a5525 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -233,6 +233,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` v `` | Toggle drag selecteer | | +| `` , h `` | Selecteer de vorige hunk | | +| `` , l `` | Selecteer de volgende hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | @@ -310,6 +314,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` v `` | Toggle drag selecteer | | +| `` , h `` | Selecteer de vorige hunk | | +| `` , l `` | Selecteer de volgende hunk | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 9caf2388b..7f3ee08fb 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -101,6 +101,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Przełącz zaznaczenie zakresu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | @@ -205,6 +209,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Przełącz zaznaczenie zakresu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 32fe874b2..d6a1ee3bb 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -237,6 +237,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` v `` | Toggle range select | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Pesquisar na visualização atual por texto | | @@ -341,6 +345,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` v `` | Toggle range select | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Pesquisar na visualização atual por texto | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 33a49e396..cb6e1dd1b 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -76,6 +76,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Переключить выборку перетаскивания | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Найти | | @@ -110,6 +114,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` v `` | Переключить выборку перетаскивания | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | Exit back to side panel | | | `` / `` | Найти | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 91226ebc1..41659cd0a 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -288,6 +288,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` v `` | 切换拖动选择 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | @@ -338,6 +342,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` v `` | 切换拖动选择 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 68a881f17..20fd69618 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -77,6 +77,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` v `` | 切換拖曳選擇 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到側邊面板 | | | `` / `` | 搜尋 | | @@ -360,6 +364,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | | `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | | `` v `` | 切換拖曳選擇 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | +| `` N `` | Go to previous file | | +| `` n `` | Go to next file | | | `` `` | 退出回到側邊面板 | | | `` / `` | 搜尋 | | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index f3e4597d6..0d0aa327b 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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{"", "h"}, NextHunk: Keybinding{"", "l"}, + PrevFile: Keybinding{"N"}, + NextFile: Keybinding{"n"}, ToggleSelectHunk: Keybinding{"a"}, PickBothHunks: Keybinding{"b"}, EditSelectHunk: Keybinding{"E"}, diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go index 3f7522ec2..6437e8458 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries.go +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -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. diff --git a/pkg/gui/controllers/helpers/diff_line_queries_test.go b/pkg/gui/controllers/helpers/diff_line_queries_test.go index 79a9ab699..47769ad98 100644 --- a/pkg/gui/controllers/helpers/diff_line_queries_test.go +++ b/pkg/gui/controllers/helpers/diff_line_queries_test.go @@ -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) + } + }) + } +} diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 74c034fc6..bb61bebcf 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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() diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 725dd5fe1..e54a52d5f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index dfae58c4b..c8528be22 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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) diff --git a/pkg/integration/tests/main_view/file_navigation_scrolls_to_the_top.go b/pkg/integration/tests/main_view/file_navigation_scrolls_to_the_top.go new file mode 100644 index 000000000..1f8c957ba --- /dev/null +++ b/pkg/integration/tests/main_view/file_navigation_scrolls_to_the_top.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go b/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go new file mode 100644 index 000000000..519e24dab --- /dev/null +++ b/pkg/integration/tests/main_view/navigate_by_hunk_and_file.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go index afbd59f5e..652b8adea 100644 --- a/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go +++ b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go @@ -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() diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index e2575b168..674637cac 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/schema-master/config.json b/schema-master/config.json index f2bef0066..13ca47156 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -2461,6 +2461,34 @@ "l" ] }, + "prevFile": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "N" + }, + "nextFile": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "n" + }, "toggleSelectHunk": { "oneOf": [ {