Compare commits

..
Author SHA1 Message Date
github-actions[bot] 0c908a9461 README.md: Update Sponsors 2026-09-27 09:24:27 +00:00
85 changed files with 277 additions and 5447 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -845,8 +845,6 @@ keybinding:
main:
prevHunk: [<left>, h]
nextHunk: [<right>, l]
prevFile: "N"
nextFile: "n"
toggleSelectHunk: a
pickBothHunks: b
editSelectHunk: E
-14
View File
@@ -223,13 +223,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | 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 | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
@@ -335,13 +328,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 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 | |
| `` e `` | Edit file | Open file in external editor. |
| `` <left>, h `` | Go to previous hunk | |
| `` <right>, l `` | Go to next hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |
-14
View File
@@ -192,13 +192,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
@@ -312,13 +305,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 範囲選択を切り替え | |
| `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 |
| `` <left>, h `` | 前のハンクに移動 | |
| `` <right>, l `` | 次のハンクに移動 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | |
-14
View File
@@ -84,13 +84,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
@@ -169,13 +162,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | 드래그 선택 전환 | |
| `` e `` | 파일 편집 | Open file in external editor. |
| `` <left>, h `` | 이전 hunk를 선택 | |
| `` <right>, l `` | 다음 hunk를 선택 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
-14
View File
@@ -231,13 +231,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | 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 | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
@@ -313,13 +306,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 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 | |
| `` e `` | Verander bestand | Open bestand in externe editor. |
| `` <left>, h `` | Selecteer de vorige hunk | |
| `` <right>, l `` | Selecteer de volgende hunk | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |
-14
View File
@@ -99,13 +99,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 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 | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
@@ -208,13 +201,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | 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 | |
| `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. |
| `` <left>, h `` | Idź do poprzedniego fragmentu | |
| `` <right>, l `` | Idź do następnego fragmentu | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |
-14
View File
@@ -235,13 +235,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | 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 | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
@@ -344,13 +337,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 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 | |
| `` e `` | Editar arquivo | Abrir arquivo no editor externo. |
| `` <left>, h `` | Ir para o local anterior | |
| `` <right>, l `` | Ir para o próximo trecho | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |
-14
View File
@@ -74,13 +74,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -113,13 +106,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
| `` v `` | Переключить выборку перетаскивания | |
| `` e `` | Редактировать файл | Open file in external editor. |
| `` <left>, h `` | Выбрать предыдущую часть | |
| `` <right>, l `` | Выбрать следующую часть | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@@ -286,13 +286,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -341,13 +334,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
| `` v `` | 切换拖动选择 | |
| `` e `` | 编辑文件 | 使用外部编辑器打开文件 |
| `` <left>, h `` | 选择上一个区块 | |
| `` <right>, l `` | 选择下一个区块 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |
@@ -75,13 +75,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
@@ -363,13 +356,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| Key | Action | Info |
|-----|--------|-------------|
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
| `` v `` | 切換拖曳選擇 | |
| `` e `` | 編輯檔案 | 使用外部編輯器開啟 |
| `` <left>, h `` | 選擇上一段 | |
| `` <right>, l `` | 選擇下一段 | |
| `` N `` | Go to previous file | |
| `` n `` | Go to next file | |
| `` <esc> `` | 退出回到側邊面板 | |
| `` / `` | 搜尋 | |
-5
View File
@@ -16,11 +16,6 @@ type Hunk struct {
newStart int
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
headerContext string
// the lengths declared in the header line ('2' and '3' in the above example),
// kept so that we can check the parsed body against them (see
// Patch.IsWellFormed). Only set by Parse.
declaredOldLength int
declaredNewLength int
// the body of the hunk, excluding the header line
bodyLines []*PatchLine
}
+11 -26
View File
@@ -7,9 +7,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
// Captures, in order: the old start, the old length (omitted by git when it is
// 1), the new start, the new length (likewise), and the trailing context.
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
func Parse(patchStr string) *Patch {
// ignore trailing newline.
@@ -21,15 +19,13 @@ func Parse(patchStr string) *Patch {
var currentHunk *Hunk
for _, line := range lines {
if strings.HasPrefix(line, "@@") {
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
oldStart, newStart, headerContext := headerInfo(line)
currentHunk = &Hunk{
oldStart: oldStart,
newStart: newStart,
declaredOldLength: oldLength,
declaredNewLength: newLength,
headerContext: headerContext,
bodyLines: []*PatchLine{},
oldStart: oldStart,
newStart: newStart,
headerContext: headerContext,
bodyLines: []*PatchLine{},
}
hunks = append(hunks, currentHunk)
} else if currentHunk != nil {
@@ -45,25 +41,14 @@ func Parse(patchStr string) *Patch {
}
}
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
func headerInfo(header string) (int, int, string) {
match := hunkHeaderRegexp.FindStringSubmatch(header)
oldStart = utils.MustConvertToInt(match[1])
oldLength = declaredLength(match[2])
newStart = utils.MustConvertToInt(match[3])
newLength = declaredLength(match[4])
headerContext = match[5]
oldStart := utils.MustConvertToInt(match[1])
newStart := utils.MustConvertToInt(match[2])
headerContext := match[3]
return oldStart, oldLength, newStart, newLength, headerContext
}
// declaredLength parses a length capture of a hunk header, which git omits when
// it is 1 (e.g. "@@ -0,0 +1 @@").
func declaredLength(match string) int {
if match == "" {
return 1
}
return utils.MustConvertToInt(match)
return oldStart, newStart, headerContext
}
func newHunkLine(line string) *PatchLine {
-69
View File
@@ -79,44 +79,6 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
}
// IsWellFormed reports whether every hunk's body matches the lengths declared in
// its header. A faithful unified diff always satisfies this; a rendering that
// restructured the diff body does not — a diff renderer that puts line numbers in
// a gutter, say, shifts the +/- marker off the start of each line, so every body
// line reads as context and the computed lengths no longer match the header. That
// makes this the test for whether a rendered diff can be parsed as a unified diff
// at all, rather than trusting a mis-parse. Only meaningful for patches produced
// by Parse, which is where the declared lengths come from.
func (self *Patch) IsWellFormed() bool {
return self.isWellFormed(false)
}
// IsWellFormedSoFar is IsWellFormed for a patch parsed from a diff we have only the
// beginning of. Its last hunk holds the first lines of a body that hasn't all arrived,
// so every hunk but the last has to match its header exactly, as before, while the last
// one only has to fit within what its header declares.
//
// The check exists to tell a faithful rendering from a restructured one, and it still
// does that. A rendering that moves the +/- marker off the start of the line makes us
// read a change as context, and a context line counts towards both lengths, so such a
// hunk comes out longer than its header declares rather than shorter.
func (self *Patch) IsWellFormedSoFar() bool {
return self.isWellFormed(true)
}
func (self *Patch) isWellFormed(lastHunkMayBeIncomplete bool) bool {
for i, hunk := range self.hunks {
if lastHunkMayBeIncomplete && i == len(self.hunks)-1 {
return hunk.oldLength() <= hunk.declaredOldLength &&
hunk.newLength() <= hunk.declaredNewLength
}
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
return false
}
}
return true
}
func (self *Patch) ContainsChanges() bool {
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
return hunk.containsChanges()
@@ -152,37 +114,6 @@ func (self *Patch) LineNumberOfLine(idx int) int {
return hunk.newStart + offset
}
// Takes a line index in the patch and returns the line number in the old file.
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
// the line's position in the old file (additions get the position they sit at).
// If the line is a header line, returns 1.
// If the line is a hunk header line, returns the first old-file line number in that hunk.
// If the line is out of range below, returns the last old-file line number in the last hunk.
func (self *Patch) OldLineNumberOfLine(idx int) int {
if idx < len(self.header) || len(self.hunks) == 0 {
return 1
}
hunkIdx := self.HunkContainingLine(idx)
// cursor out of range, return last file line number
if hunkIdx == -1 {
lastHunk := self.hunks[len(self.hunks)-1]
return lastHunk.oldStart + lastHunk.oldLength() - 1
}
hunk := self.hunks[hunkIdx]
hunkStartIdx := self.HunkStartIdx(hunkIdx)
idxInHunk := idx - hunkStartIdx
if idxInHunk == 0 {
return hunk.oldStart
}
lines := hunk.bodyLines[:idxInHunk-1]
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
return hunk.oldStart + offset
}
// Returns hunk index containing the line at the given patch line index
func (self *Patch) HunkContainingLine(idx int) int {
for hunkIdx, hunk := range self.hunks {
-156
View File
@@ -120,20 +120,6 @@ index 9320895..6d79956 100644
lemon
`
// Two deletions with no line between them: they share a new-file line number
// (both sit at the same new-file position), so only their old-file line numbers
// tell them apart.
const consecutiveDeletions = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
`
const newFile = `diff --git a/newfile b/newfile
new file mode 100644
index 0000000..4e680cc
@@ -696,148 +682,6 @@ func TestLineNumberOfLine(t *testing.T) {
}
}
func TestIsWellFormed(t *testing.T) {
// The body of a diff as rendered with the +/- markers moved out of the text
// and into a gutter: every body line now reads as context, so the lengths no
// longer match the header.
const gutterMangled = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"renameWithModificationDiff", renameWithModificationDiff, true},
{"addNewlineToEndOfFile", addNewlineToEndOfFile, true},
{"twoHunks", twoHunks, true},
{"consecutiveDeletions", consecutiveDeletions, true},
{"newFile", newFile, true},
{"deletedFile", deletedFile, true},
{"addNewlineToPreviouslyEmptyFile", addNewlineToPreviouslyEmptyFile, true},
{"exampleHunk", exampleHunk, true},
{"gutterMangled", gutterMangled, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormed())
})
}
}
func TestIsWellFormedSoFar(t *testing.T) {
// A diff read only as far as the middle of its second hunk.
const cutShort = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
+orange
...
...
...
@@ -8,6 +8,8 @@ grape
...
...
`
// The same diff cut short in its first hunk, so that the second is missing
// entirely rather than short.
const cutShortInTheFirstHunk = `diff --git a/filename b/filename
index e48a11c..b2ab81b 100644
--- a/filename
+++ b/filename
@@ -1,5 +1,5 @@
apple
-grape
`
// A rendering with the +/- markers moved into a gutter, cut short: reading the
// changes as context makes the hunk longer than its header declares, not shorter,
// so it doesn't pass for a diff we only have the beginning of.
const gutterMangledAndCutShort = `diff --git a/filename b/filename
index 9320895..6d79956 100644
--- a/filename
+++ b/filename
@@ -1,4 +1,2 @@
apple
grape
pear
lemon
melon
`
scenarios := []struct {
testName string
patchStr string
expected bool
}{
{"simpleDiff", simpleDiff, true},
{"twoHunks", twoHunks, true},
{"cutShort", cutShort, true},
{"cutShortInTheFirstHunk", cutShortInTheFirstHunk, true},
{"gutterMangledAndCutShort", gutterMangledAndCutShort, false},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
assert.Equal(t, s.expected, Parse(s.patchStr).IsWellFormedSoFar())
})
}
}
func TestOldLineNumberOfLine(t *testing.T) {
type scenario struct {
testName string
patchStr string
indexes []int
expecteds []int
}
scenarios := []scenario{
{
testName: "twoChangesInOneHunk",
patchStr: twoChangesInOneHunk,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5, 5},
},
{
testName: "consecutiveDeletions",
patchStr: consecutiveDeletions,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 2, 3, 4, 4},
},
{
testName: "renameWithModificationDiff",
patchStr: renameWithModificationDiff,
indexes: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 1000},
expecteds: []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 3, 4, 5, 5},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
for i, idx := range s.indexes {
patch := Parse(s.patchStr)
result := patch.OldLineNumberOfLine(idx)
assert.Equal(t, s.expecteds[i], result)
}
})
}
}
func TestGetNextStageableLineIndex(t *testing.T) {
type scenario struct {
testName string
-4
View File
@@ -672,8 +672,6 @@ 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"`
@@ -1184,8 +1182,6 @@ 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"},
+20 -97
View File
@@ -20,25 +20,6 @@ type escapeInterpreter struct {
instruction instruction
hyperlink strings.Builder
// the digits of the OSC number seen so far, while we don't yet know which
// OSC this is
oscNumber strings.Builder
// the payload of an OSC 1717 sequence, in which a diff renderer states
// which line of which file it is about to render; accumulated like
// hyperlink, and attached to the cells that follow it
metadata strings.Builder
// whether the payload currently in metadata has reached a cell, so that one
// that never does can be recognized and kept as an orphan
metadataConsumed bool
// OSC 1717 payloads that no cell took, because the next record followed
// with nothing rendered in between. A renderer emits records back to back
// wherever two diff lines share a rendered line — the deletion and the
// addition of a modification collapsed into one column, or a banner
// announcing a file and its first hunk at once. The write loop gives these
// cells of their own, so that a line keeps every record it was given rather
// than only the last.
orphanedMetadata []string
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
// rows rather than emitting LFs for them. To convert those into row
// advances the view can act on, we track where in the pseudo-terminal
@@ -101,9 +82,9 @@ const (
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCMetadata
stateOSCEndEscape
stateOSCSkipUnknown
@@ -446,42 +427,27 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
}
return true, nil
case stateOSC:
// Accumulate the OSC number until the ';' that terminates it, then
// dispatch on the whole number rather than on a single digit.
switch {
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
ei.oscNumber.WriteByte(ch[0])
return true, nil
case characterEquals(ch, ';'):
switch ei.oscNumber.String() {
case "8":
ei.hyperlink.Reset()
ei.state = stateOSCParams
case "1717":
ei.orphanUnconsumedMetadata()
ei.state = stateOSCMetadata
default:
ei.state = stateOSCSkipUnknown
}
ei.oscNumber.Reset()
return true, nil
default:
// Not an OSC we understand — it has no number, or a character
// follows the number where the ';' should be. Rather than
// erroring, which would reset state mid-OSC and leak the rest of
// the sequence into the view as literal text, skip to its
// terminator, which this character may already be.
ei.oscNumber.Reset()
switch {
case characterEquals(ch, 0x07):
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.state = stateOSCEndEscape
default:
ei.state = stateOSCSkipUnknown
}
if characterEquals(ch, '8') {
ei.state = stateOSCWaitForParams
ei.hyperlink.Reset()
return true, nil
}
ei.state = stateOSCSkipUnknown
return true, nil
case stateOSCWaitForParams:
if !characterEquals(ch, ';') {
// Malformed OSC 8 (expected ';' after '8'). Rather than
// erroring — which would reset state mid-OSC and cause the
// rest of the sequence to leak as literal text — treat the
// whole OSC as one we don't understand and skip to its
// terminator.
ei.state = stateOSCSkipUnknown
return true, nil
}
ei.state = stateOSCParams
return true, nil
case stateOSCParams:
if characterEquals(ch, ';') {
ei.state = stateOSCHyperlink
@@ -497,18 +463,6 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
ei.hyperlink.Write(ch)
}
return true, nil
case stateOSCMetadata:
switch {
case characterEquals(ch, 0x07):
ei.dropMetadataIfHandshake()
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.dropMetadataIfHandshake()
ei.state = stateOSCEndEscape
default:
ei.metadata.Write(ch)
}
return true, nil
case stateOSCEndEscape:
ei.state = stateNone
return true, nil
@@ -524,37 +478,6 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
return false, nil
}
// orphanUnconsumedMetadata clears the metadata accumulator for a new OSC 1717
// record, keeping the payload it held as an orphan if no cell took it (see
// orphanedMetadata).
func (ei *escapeInterpreter) orphanUnconsumedMetadata() {
if ei.metadata.Len() > 0 && !ei.metadataConsumed {
ei.orphanedMetadata = append(ei.orphanedMetadata, ei.metadata.String())
}
ei.metadata.Reset()
ei.metadataConsumed = false
}
// takeOrphanedMetadata hands the accumulated orphaned payloads to the caller and
// clears the list.
func (ei *escapeInterpreter) takeOrphanedMetadata() []string {
result := ei.orphanedMetadata
ei.orphanedMetadata = nil
return result
}
// dropMetadataIfHandshake discards a just-completed OSC 1717 payload that
// carries nothing beyond the version. A diff renderer emits such a record ahead
// of everything else to announce that it speaks the protocol, so that a host can
// find that out by asking rather than by inspecting a rendering. It says nothing
// about any line, so it must not attach to the line that follows it; a per-line
// record always has fields, and is kept.
func (ei *escapeInterpreter) dropMetadataIfHandshake() {
if !strings.Contains(ei.metadata.String(), ";") {
ei.metadata.Reset()
}
}
func (ei *escapeInterpreter) outputCSI() error {
n := len(ei.csiParam)
for i := 0; i < n; {
-1
View File
@@ -167,7 +167,6 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
"\x1b[0 q", // intermediate byte after a param
"\x1b[1;;m", // malformed SGR: empty middle param
"\x1b]8bogus\x07", // OSC 8 missing ';'
"\x1b]1337;File=inline=1\x07", // OSC with a number we don't implement
"\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap
"\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params
}
+2 -2
View File
@@ -438,7 +438,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.RewrapContent()
v.ClearViewLines()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@@ -1576,7 +1576,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.RewrapContent()
v.ClearViewLines()
}
}
g.maxX, g.maxY = maxX, maxY
+22 -361
View File
@@ -5,7 +5,6 @@
package gocui
import (
"bytes"
"fmt"
"io"
"slices"
@@ -164,12 +163,6 @@ type View struct {
// instead of Sel{Bg,Fg}Colors for highlighting selected lines.
HighlightInactive bool
// If SelectedLineColorWidth is greater than zero, a highlighted line is painted
// in the selection colors on that many columns at its left edge only, rather
// than across its whole width, leaving the line's own colors to show through.
// For content that conveys meaning by color of its own.
SelectedLineColorWidth int
// If Frame is true, a border will be drawn around the view.
Frame bool
@@ -258,138 +251,23 @@ type pos struct {
x, y int
}
// call this if you want to render new content without the chance of old content
// still appearing, or if you want to remove a line from the existing content. For
// a view whose size has changed, whose content is the same but has to be wrapped
// afresh, call RewrapContent instead.
// call this in the event of a view resize, or if you want to render new content
// without the chance of old content still appearing, or if you want to remove
// a line from the existing content
func (v *View) clearViewLines() {
v.tainted = true
v.viewLines = nil
v.clearHover()
}
// RewrapContent wraps the view's content for the size the view has now, and puts
// the positions into that content — the scroll offset, the cursor, a range's
// anchor — back on the lines they were on. They are all view lines, which count
// the segments each line is wrapped into, so wrapping the content at another
// width leaves every one of them pointing at a different line.
//
// Call it on the UI thread whenever the view's size changes; a task goroutine may
// be writing the content concurrently, and all of this is state writeMutex
// protects.
func (v *View) RewrapContent() {
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
origin := v.contentPosOf(v.oy)
cursor := v.contentPosOf(v.oy + v.cy)
anchor := v.contentPosOf(v.rangeSelectStartY)
cursorRow := v.cy
v.clearViewLines()
v.refreshViewLinesIfNeeded()
if !origin.ok {
return
}
cursorLine, cursorOk := v.viewLineOf(cursor)
if anchorLine, ok := v.viewLineOf(anchor); ok {
v.rangeSelectStartY = anchorLine
if cursorOk {
// A range covers lines of content, not the wrapped segments those
// lines are drawn as, so its ends go back on the outermost segments
// of their lines: a line that was covered whole stays covered whole.
cursorLine = v.viewLineOfRangeEnd(cursor, anchor)
v.rangeSelectStartY = v.viewLineOfRangeEnd(anchor, cursor)
}
}
// The line the cursor is on keeps the row it was drawn on, so that it doesn't
// move under the user; with no cursor on screen the view keeps its own place
// in the content instead.
if v.Highlight && cursorOk && cursorRow >= 0 && cursorRow < v.InnerHeight() {
v.SetOriginY(cursorLine - cursorRow)
} else if originLine, ok := v.viewLineOf(origin); ok {
v.SetOriginY(originLine)
}
if cursorOk {
v.cy = cursorLine - v.oy
}
}
// contentPos is a position in a view's content in terms that survive the content
// being wrapped again: which line of it, and which of that line's segments.
type contentPos struct {
line, segment int
ok bool
}
// contentPosOf returns where the given view line sits in the content. Only call
// this with a lock on writeMutex, and with the view lines up to date.
func (v *View) contentPosOf(viewLine int) contentPos {
if viewLine < 0 || viewLine >= len(v.viewLines) {
return contentPos{}
}
return contentPos{
line: v.viewLines[viewLine].linesY,
segment: v.viewLines[viewLine].linesX,
ok: true,
}
}
// viewLineOf returns the view line drawing the given position in the content,
// on the nearest segment its line still has. Only call this with a lock on
// writeMutex, and with the view lines up to date.
func (v *View) viewLineOf(pos contentPos) (int, bool) {
first, last, ok := v.segmentSpanOf(pos)
if !ok {
return 0, false
}
return min(first+pos.segment, last), true
}
// viewLineOfRangeEnd returns the view line for one end of a range selection: the
// outermost segment of its line, so that the range covers that line whole. other
// is the range's other end, which says which way is outward. Both ends have to be
// positions whose lines are drawn, which viewLineOf answers.
func (v *View) viewLineOfRangeEnd(pos contentPos, other contentPos) int {
first, last, _ := v.segmentSpanOf(pos)
if pos.line <= other.line {
return first
}
return last
}
// segmentSpanOf returns the first and last view line drawing the given position's
// line of the content. ok is false when the position was never taken, or its line
// isn't drawn at all.
func (v *View) segmentSpanOf(pos contentPos) (int, int, bool) {
if !pos.ok {
return 0, 0, false
}
return v.viewLineSpanOfBufferLine(pos.line)
}
// viewLineSpanOfBufferLine returns the first and last view line drawing the given
// buffer line, i.e. the first and last segment it is wrapped into. Both are the
// same view line when the line doesn't wrap. ok is false when the line isn't drawn
// at all. Only call this with a lock on writeMutex, and with the view lines up to
// date.
func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) {
first, last := -1, -1
for i, vline := range v.viewLines {
if vline.linesY == bufferLine {
if first == -1 {
first = i
}
last = i
} else if first != -1 {
break
}
}
return first, last, first != -1
}
type searcher struct {
@@ -627,12 +505,6 @@ func (v *View) SetRangeSelectStart(rangeSelectStartY int) {
v.rangeSelectStartY = rangeSelectStartY
}
// RangeSelectStartY returns the view line the range selection is anchored on,
// or -1 when there is no range.
func (v *View) RangeSelectStartY() int {
return v.rangeSelectStartY
}
func (v *View) CancelRangeSelect() {
v.rangeSelectStartY = -1
}
@@ -706,23 +578,6 @@ type lineType struct {
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
// asWritten is the text of the line as its writer wrote it, escape sequences
// left out, kept from the first character on that the cells spell differently:
// a tab, which the cells hold as the spaces it fills, or a carriage return,
// which they hold as the overwrite it caused. nil while the cells spell the
// line as it was written, as they do for most lines.
asWritten []byte
}
// textAsWritten returns the line's text as its writer wrote it, escape sequences
// left out. A reader that parses a view's content rather than showing it wants
// this form; the cells' text is for showing.
func (l *lineType) textAsWritten() string {
if l.asWritten != nil {
return string(l.asWritten)
}
return l.cells.String()
}
// trailingFillAttributes describes the fg/bg colors that draw() should
@@ -739,9 +594,6 @@ type cell struct {
width int // number of terminal cells occupied by chr (always 1 or 2)
bgColor, fgColor Attribute
hyperlink string
// the OSC 1717 payload in effect when the cell was written, i.e. what the
// diff renderer said about the diff line this cell is part of
metadata string
}
type cells []cell
@@ -875,8 +727,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
}
colorWidth := v.SelectedLineColorWidth
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
if y >= rangeSelectStart && y <= rangeSelectEnd {
fgColor = applySelTextColor(fgColor, v.SelTextColor)
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
@@ -984,7 +835,8 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.buf.seekWrite(x, y)
v.buf.wx = x
v.buf.wy = y
// Changing the write position makes a pending newline obsolete
v.buf.pendingNewline = false
@@ -1068,35 +920,6 @@ func (b *viewBuffer) writeCells(cells []cell) {
b.wx += len(cells)
}
// seekWrite moves the write cursor to (x, y). Writing there starts the line over,
// so whatever it kept of its text as written is dropped; the text a line keeps is
// the text written to it from its start. A carriage return continues a line
// instead, and moves the cursor without this (see write).
func (b *viewBuffer) seekWrite(x, y int) {
b.wx = x
b.wy = y
if y < len(b.lines) {
b.lines[y].asWritten = nil
}
}
// startAsWritten begins keeping the current line's text as written (see
// lineType.asWritten), at the first character the cells won't spell the same way.
// Up to here they spell it exactly, so their text is what was written so far.
func (b *viewBuffer) startAsWritten() {
if line := &b.lines[b.wy]; line.asWritten == nil {
line.asWritten = append([]byte{}, line.cells.String()...)
}
}
// noteAsWritten records text the writer wrote to the current line, once the line
// keeps its text as written at all.
func (b *viewBuffer) noteAsWritten(text []byte) {
if line := &b.lines[b.wy]; line.asWritten != nil {
line.asWritten = append(line.asWritten, text...)
}
}
// Write appends a byte slice into the view's internal buffer. Because
// View implements the io.Writer interface, it can be passed as parameter
// of functions like fmt.Fprintf, fmt.Fprintln, io.Copy, etc. Clear must
@@ -1140,30 +963,14 @@ func (b *viewBuffer) write(v *View, p []byte) {
finishLine := func() {
b.autoRenderHyperlinksInCurrentLine(v)
// A record that reached the line's end without covering a cell still
// belongs to the line: an orphan (see escapeInterpreter.orphanedMetadata),
// or the record of a changed line that is empty, which a renderer emits
// with nothing but the newline after it. Give each a cell of its own, so
// that the line is still recognizable as the diff line it renders rather
// than as nothing at all.
for _, payload := range b.ei.takeOrphanedMetadata() {
b.writeCells([]cell{{metadata: payload}})
}
if b.ei.metadata.Len() > 0 && !b.ei.metadataConsumed {
b.writeCells([]cell{{metadata: b.ei.metadata.String()}})
b.ei.metadataConsumed = true
}
}
advanceToNextLine := func() {
b.seekWrite(0, b.wy+1)
b.wx = 0
b.wy++
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
// An OSC 1717 record describes the line it precedes and is never
// closed, so it stops applying at the line's end; a renderer emits a
// fresh one for each line it has something to say about.
b.ei.metadata.Reset()
}
if b.pendingNewline {
@@ -1193,10 +1000,6 @@ func (b *viewBuffer) write(v *View, p []byte) {
b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
// The cells will hold what follows as an overwrite of what came
// before; the text as written keeps the return itself.
b.startAsWritten()
b.noteAsWritten(chr)
b.wx = 0
b.ei.notifyColumnReset()
default:
@@ -1312,18 +1115,8 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
truncateLine := false
isEscape, err := b.ei.parseOne(ch)
// A record that the next one superseded before any cell took it still
// belongs to this line (see escapeInterpreter.orphanedMetadata); give each
// a cell of its own, in the order they were emitted, ahead of whatever this
// character produces.
for _, payload := range b.ei.takeOrphanedMetadata() {
cells = append(cells, cell{metadata: payload})
}
if err != nil {
characters := b.ei.characters()
for _, chr := range characters {
for _, chr := range b.ei.characters() {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@@ -1332,7 +1125,6 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
}
cells = append(cells, c)
}
b.noteAsWritten([]byte(strings.Join(characters, "")))
b.ei.reset()
} else {
repeatCount := 1
@@ -1349,7 +1141,7 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, cells
return truncateLine, []cell{}
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
@@ -1358,19 +1150,10 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
repeatCount = cf.n
ch = []byte{' '}
width = 1
b.noteAsWritten(bytes.Repeat(ch, repeatCount))
} else if isEscape {
// the escape itself outputs nothing, but any cells carrying an
// orphaned record still need writing
if len(cells) == 0 {
return truncateLine, nil
}
return truncateLine, cells
// do not output anything
return truncateLine, nil
} else if characterEquals(ch, '\t') {
// The cells hold a tab as the spaces it fills; the text as written
// keeps the tab itself.
b.startAsWritten()
b.noteAsWritten(ch)
// fill tab-sized space
tabWidth := v.TabWidth
if tabWidth < 1 {
@@ -1379,20 +1162,14 @@ func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bo
ch = []byte{' '}
width = 1
repeatCount = tabWidth - (x % tabWidth)
} else {
b.noteAsWritten(ch)
}
c := cell{
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
metadata: b.ei.metadata.String(),
chr: string(ch),
width: width,
}
if c.metadata != "" {
b.ei.metadataConsumed = true
}
for range repeatCount {
cells = append(cells, c)
}
@@ -1968,88 +1745,6 @@ func (v *View) BufferLines() []string {
return lines
}
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
// to recover which line of which file it came from: the line's text, which can be
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
// records a diff renderer attached to it, which state the answer outright.
type DiffLineContent struct {
// The line's text as its writer wrote it, escape sequences left out, where
// BufferLines gives the text as the cells spell it. The two differ where the
// cells can't spell what was written: a tab, which they hold as the spaces it
// fills, and a carriage return, which they hold as the overwrite it caused.
// git terminates a path containing a space with a tab in a diff header, and a
// parser of the diff has to see the tab.
Text string
// The distinct OSC 1717 payloads carried by the line's cells, in
// left-to-right order. A single-column rendering tags every cell of a line
// with the same payload, so there is one; a side-by-side rendering tags
// each side separately, so a line showing a deletion beside the addition
// that replaces it carries both.
Metadata []string
}
// DiffLineContents returns the per-line material a diff-line reader works from
// (see DiffLineContent), indexed by unwrapped buffer line. Text and records are
// snapshotted in a single locked pass, so they stay consistent with each other
// and with the buffer they came from even while a re-render rebuilds it.
func (v *View) DiffLineContents() []DiffLineContent {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
contents := make([]DiffLineContent, len(v.buf.lines))
for i := range v.buf.lines {
line := &v.buf.lines[i]
var metadata []string
for _, c := range line.cells {
if c.metadata != "" && !slices.Contains(metadata, c.metadata) {
metadata = append(metadata, c.metadata)
}
}
contents[i] = DiffLineContent{Text: line.textAsWritten(), Metadata: metadata}
}
return contents
}
// BufferLineForViewLine maps a view line index (which counts wrapped lines) to
// the index of the corresponding line in the unwrapped internal buffer (as
// returned by BufferLines). Several view lines map to the same buffer line when
// that line wraps. Returns false if the view line is out of range.
func (v *View) BufferLineForViewLine(y int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.bufferLineForViewLine(y)
}
// ViewLineForBufferLine maps an unwrapped buffer line index to the index of the
// first view line that renders it — the inverse of BufferLineForViewLine, for
// turning a line found by examining the buffer into a line to scroll to or
// select. Returns false if the buffer line isn't rendered into any view line.
func (v *View) ViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
first, _, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return first, ok
}
// LastViewLineForBufferLine maps an unwrapped buffer line index to the index of
// the last view line that renders it, which for a line that doesn't wrap is the
// same as the first. It is where the far end of a range goes: a range is over
// buffer lines, so it has to cover the last one of them to its final segment
// rather than stopping where that line begins.
func (v *View) LastViewLineForBufferLine(bufferLineIdx int) (int, bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
_, last, ok := v.viewLineSpanOfBufferLine(bufferLineIdx)
return last, ok
}
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
@@ -2076,9 +1771,6 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return len(v.buf.lines)
}
@@ -2288,26 +1980,16 @@ func (v *View) SelectedLineIdx() int {
return seletedLineIdx
}
// MiddleVisibleLineIdx returns the view line halfway down the visible content. It
// stands in for a cursor in a view that has none: of the lines on screen, the one in
// the middle is the likeliest to be the one being read.
func (v *View) MiddleVisibleLineIdx() int {
top := v.OriginY()
bottom := min(top+v.InnerHeight(), v.ViewLinesHeight())
return (top + bottom) / 2
}
// expected to only be used in tests
func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
idx, ok := v.bufferLineForViewLine(v.SelectedLineIdx())
if !ok {
if len(v.buf.lines) == 0 {
return ""
}
return v.lineContentAtIdx(idx)
return v.lineContentAtIdx(v.SelectedLineIdx())
}
// expected to only be used in tests
@@ -2322,17 +2004,8 @@ func (v *View) SelectedLines() []string {
startIdx, endIdx := v.SelectedLineRange()
lines := make([]string, 0, endIdx-startIdx+1)
previous := -1
for i := startIdx; i <= endIdx; i++ {
// The selection is in view lines, which count the segments a wrapped line
// is drawn as; a line the selection covers several segments of is still
// the one line it is.
idx, ok := v.bufferLineForViewLine(i)
if !ok || idx == previous {
continue
}
previous = idx
lines = append(lines, v.lineContentAtIdx(idx))
lines = append(lines, v.lineContentAtIdx(i))
}
return lines
@@ -2342,19 +2015,6 @@ func (v *View) lineContentAtIdx(idx int) string {
return v.buf.lines[idx].cells.String()
}
// bufferLineForViewLine maps a view line index, which counts the wrapped
// segments of the lines it draws, to the index of the line of content it is a
// segment of. Only call this with a lock on writeMutex.
func (v *View) bufferLineForViewLine(y int) (int, bool) {
v.refreshViewLinesIfNeeded()
if y < 0 || y >= len(v.viewLines) {
return 0, false
}
return v.viewLines[y].linesY, true
}
func (v *View) SelectedPoint() (int, int) {
cx, cy := v.Cursor()
ox, oy := v.Origin()
@@ -2425,7 +2085,8 @@ func (v *View) ClearTextArea() {
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.SetWritePos(0, y)
v.buf.wx = 0
v.buf.wy = y
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
-299
View File
@@ -113,99 +113,6 @@ func TestWriteString(t *testing.T) {
}
}
func TestOverwriteLinesAfterContentEndingInANewline(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
// The trailing newline is held back until more content arrives, so that the
// view doesn't end in an empty line.
v.writeString("a\nb\n")
v.OverwriteLines(0, "x")
assert.Equal(t, []string{"x", "b"}, v.BufferLines())
}
// diffLineTexts returns the Text of each of the given contents.
func diffLineTexts(contents []DiffLineContent) []string {
return lo.Map(contents, func(c DiffLineContent, _ int) string { return c.Text })
}
func TestDiffLineContentsTextIsTheTextAsWritten(t *testing.T) {
tests := []struct {
name string
stringsToWrite []string
expectedShown []string
expectedAsWritten []string
}{
{
name: "a line the cells spell as written",
stringsToWrite: []string{"abc\n"},
expectedShown: []string{"abc"},
expectedAsWritten: []string{"abc"},
},
{
name: "a tab is kept rather than the spaces it fills",
stringsToWrite: []string{"a\tb\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "a carriage return is kept rather than the overwrite it causes",
stringsToWrite: []string{"abc\rde\n"},
expectedShown: []string{"dec"},
expectedAsWritten: []string{"abc\rde"},
},
{
// git writes a CRLF file's lines as "+foo\r", the color reset, "\n".
name: "escape sequences are left out",
stringsToWrite: []string{"\x1b[32m+foo\r\x1b[m\n"},
expectedShown: []string{"+foo"},
expectedAsWritten: []string{"+foo\r"},
},
{
// ConPTY writes a run of spaces as a cursor-forward escape.
name: "a cursor-forward escape stands for the spaces it skips",
stringsToWrite: []string{"\ta\x1b[2Cb\n"},
expectedShown: []string{" a b"},
expectedAsWritten: []string{"\ta b"},
},
{
name: "a line written in two parts",
stringsToWrite: []string{"a\t", "b\n"},
expectedShown: []string{"a b"},
expectedAsWritten: []string{"a\tb"},
},
{
name: "only the lines with a tab or a return are kept separately",
stringsToWrite: []string{"x\n", "y\tz\n", "w\n"},
expectedShown: []string{"x", "y z", "w"},
expectedAsWritten: []string{"x", "y\tz", "w"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
for _, s := range test.stringsToWrite {
v.writeString(s)
}
assert.Equal(t, test.expectedShown, v.BufferLines())
assert.Equal(t, test.expectedAsWritten, diffLineTexts(v.DiffLineContents()))
})
}
}
func TestDiffLineContentsTextOfAnOverwrittenLine(t *testing.T) {
v := NewView("name", 0, 0, 20, 10, OutputNormal)
v.writeString("a\tb\nc\td")
// Overwriting a line starts it over: what it kept of its earlier text goes,
// and the line below is left alone.
v.OverwriteLines(0, "xy")
assert.Equal(t, []string{"xy", "c d"}, v.BufferLines())
assert.Equal(t, []string{"xy", "c\td"}, diffLineTexts(v.DiffLineContents()))
}
func TestUpdatedCursorAndOrigin(t *testing.T) {
tests := []struct {
prevOrigin int
@@ -251,107 +158,6 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// osc1717 wraps an OSC 1717 payload in the sequence a diff renderer emits it in:
// the ESC ] introducer with the OSC number, and ESC \ as the terminator.
func osc1717(payload string) string {
return "\x1b]1717;" + payload + "\x1b\\"
}
func TestDiffLineContents(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer prefixes each line it renders with a record naming the
// file and the line's position in it: version;type;new-line;old-line;file.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "line1",
osc1717("1;d;2;2;foo.txt") + "old2",
osc1717("1;a;2;;foo.txt") + "new2",
"@@ a hunk header, which carries no record @@",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "line1", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2", Metadata: []string{"1;d;2;2;foo.txt"}},
{Text: "new2", Metadata: []string{"1;a;2;;foo.txt"}},
// The record of the line before doesn't bleed onto this one.
{Text: "@@ a hunk header, which carries no record @@"},
}, v.DiffLineContents())
}
func TestDiffLineContentsWithSideBySideRecords(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A side-by-side renderer puts two diff lines on one rendered line, and so
// emits a record before each half.
v.writeString(strings.Join([]string{
osc1717("1;c;1;;foo.txt") + "context " + osc1717("1;c;1;;foo.txt") + "context",
osc1717("1;d;2;2;foo.txt") + "old2 " + osc1717("1;a;2;;foo.txt") + "new2",
}, "\n"))
assert.Equal(t, []DiffLineContent{
// The two halves of a context line are the same diff line, stated twice.
{Text: "context context", Metadata: []string{"1;c;1;;foo.txt"}},
{Text: "old2 new2", Metadata: []string{"1;d;2;2;foo.txt", "1;a;2;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsOfWrappedLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A line that gocui wraps is still one buffer line, so its record covers
// every view line it is displayed on.
v.writeString(osc1717("1;a;1;;foo.txt") + "a line too long to fit")
assert.Equal(t, []DiffLineContent{
{Text: "a line too long to fit", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
assert.Equal(t, 3, v.ViewLinesHeight())
for viewLine := range 3 {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, 0, bufferLine)
}
}
func TestDiffLineContentsWithRecordsCoveringNoCell(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString(strings.Join([]string{
// A banner announcing a file and its first hunk at once carries both
// records back to back.
osc1717("1;f;;;foo.txt") + osc1717("1;h;5;;foo.txt") + "foo.txt --- Go",
// So does a modification whose deletion and addition are collapsed into
// a single rendered line.
osc1717("1;d;5;5;foo.txt") + osc1717("1;a;5;;foo.txt") + "595 new content",
// A changed line that is empty is rendered as its record and nothing else.
osc1717("1;a;6;;foo.txt"),
}, "\n") + "\n")
assert.Equal(t, []DiffLineContent{
{Text: "foo.txt --- Go", Metadata: []string{"1;f;;;foo.txt", "1;h;5;;foo.txt"}},
{Text: "595 new content", Metadata: []string{"1;d;5;5;foo.txt", "1;a;5;;foo.txt"}},
{Text: "", Metadata: []string{"1;a;6;;foo.txt"}},
}, v.DiffLineContents())
}
func TestDiffLineContentsSwallowsHandshake(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
// A diff renderer announces itself with a version-only record before the
// diff. It must leave no trace: no visible bytes, no line of its own, and
// above all no record on the line that follows it.
v.writeString(osc1717("1") + strings.Join([]string{
"diff --git a/foo.txt b/foo.txt",
osc1717("1;a;1;;foo.txt") + "added",
}, "\n"))
assert.Equal(t, []DiffLineContent{
{Text: "diff --git a/foo.txt b/foo.txt"},
{Text: "added", Metadata: []string{"1;a;1;;foo.txt"}},
}, v.DiffLineContents())
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
@@ -398,61 +204,6 @@ func TestViewLinesTruncatedByShorterRender(t *testing.T) {
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
func TestBufferLineForViewLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Buffer line 0 is short (view line 0); buffer line 1 wraps into three view
// lines (1, 2, 3); buffer line 2 is short again (view line 4).
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for viewLine, wantBufferLine := range []int{0, 1, 1, 1, 2} {
bufferLine, ok := v.BufferLineForViewLine(viewLine)
assert.True(t, ok)
assert.Equal(t, wantBufferLine, bufferLine)
}
_, ok := v.BufferLineForViewLine(5)
assert.False(t, ok)
_, ok = v.BufferLineForViewLine(-1)
assert.False(t, ok)
}
func TestViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the first of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 1, 4} {
viewLine, ok := v.ViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.ViewLineForBufferLine(3)
assert.False(t, ok)
}
func TestLastViewLineForBufferLine(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// A wrapped buffer line maps to the last of the view lines it spans.
v.writeString("short\n" + strings.Repeat("b", 27) + "\nlast")
for bufferLine, wantViewLine := range []int{0, 3, 4} {
viewLine, ok := v.LastViewLineForBufferLine(bufferLine)
assert.True(t, ok)
assert.Equal(t, wantViewLine, viewLine)
}
_, ok := v.LastViewLineForBufferLine(3)
assert.False(t, ok)
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
@@ -1069,53 +820,3 @@ func TestApplySelTextColor(t *testing.T) {
})
}
}
// A view that wraps draws one line of its content as several view lines, and the
// cursor and the range anchor count those. What is asked about a selection is
// which lines of the content it covers, so those are what it has to be reported
// in.
func TestSelectedLinesOfWrappedContent(t *testing.T) {
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// "a line that wraps" takes two view lines, so the four lines of content are
// drawn as five: "one", "two", "a line th", "at wraps", "four".
v.writeString("one\ntwo\na line that wraps\nfour\n")
assert.Equal(t, 5, v.ViewLinesHeight())
// The cursor on the wrapped line's second half is on that line.
v.FocusPoint(0, 3, false)
assert.Equal(t, "a line that wraps", v.SelectedLine())
// A range over both halves of the wrapped line covers one line of content.
v.SetRangeSelectStart(2)
assert.Equal(t, []string{"a line that wraps"}, v.SelectedLines())
}
// Resizing a view throws away the wrapping of its content and wraps it again for
// the new width, which moves every line of it to a different view line. The
// positions into the view count view lines, so they all have to come along.
func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
g := &Gui{}
v, _ := g.SetView("name", 0, 0, 11, 10, 0) // InnerWidth 10
v.Wrap = true
v.Highlight = true
// Two wrapping lines, with a single line between them: eight view lines for
// five lines of content.
v.writeString("one\na line that wraps\ntwo\nanother wrapping line\nthree\n")
assert.Equal(t, 8, v.ViewLinesHeight())
// A range over the whole of the second wrapping line, which is drawn as view
// lines 4 to 6.
v.SetRangeSelectStart(4)
v.FocusPoint(0, 6, false)
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
// Widen the view so that nothing wraps any more.
_, _ = g.SetView("name", 0, 0, 31, 10, 0) // InnerWidth 30
assert.Equal(t, 5, v.ViewLinesHeight())
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
}
+3 -14
View File
@@ -196,12 +196,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.c.GocuiGui().Cursor = v.Editable && v.Mask == ""
self.UpdateSelectionHighlights()
self.updateSelectionHighlights()
c.HandleFocus(opts)
}
// UpdateSelectionHighlights re-derives which views draw a selection, and which of
// updateSelectionHighlights re-derives which views draw a selection, and which of
// them draw theirs as the active one: a view shows a selection while its context is
// on the stack and has something to select, and the context the user is in shows the
// active selection while the ones behind it show inactive ones.
@@ -210,7 +210,7 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
// every change to the stack goes through; after a refresh, since that is when the
// contents of a list change; and from whoever tells a context that its content has
// gained or lost something to select.
func (self *ContextMgr) UpdateSelectionHighlights() {
func (self *ContextMgr) updateSelectionHighlights() {
self.RLock()
defer self.RUnlock()
@@ -399,14 +399,3 @@ func (self *ContextMgr) NextInStack(c types.Context) types.Context {
panic("context not in stack")
}
// IsInStack reports whether the given context is on the stack at all, for callers
// that can't otherwise know and would make NextInStack panic.
func (self *ContextMgr) IsInStack(c types.Context) bool {
self.RLock()
defer self.RUnlock()
return lo.ContainsBy(self.ContextStack, func(other types.Context) bool {
return other.GetKey() == c.GetKey()
})
}
+26 -18
View File
@@ -13,14 +13,15 @@ type BaseContext struct {
windowName string
onGetOptionsMap func() map[string]string
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
keybindingsFns []types.KeybindingsFn
mouseKeybindingsFns []types.MouseKeybindingsFn
onDoubleClickFn func() error
onClickFn func(opts gocui.ViewMouseBindingOpts) error
onClickFocusedMainViewFn onClickFocusedMainViewFn
onRenderToMainFn func()
onFocusFns []onFocusFn
onFocusLostFns []onFocusLostFn
onQuitFns []func()
focusable bool
transient bool
@@ -33,8 +34,9 @@ type BaseContext struct {
}
type (
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onFocusFn = func(types.OnFocusOpts)
onFocusLostFn = func(types.OnFocusLostOpts)
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
)
var _ types.IBaseContext = &BaseContext{}
@@ -120,14 +122,6 @@ func (self *BaseContext) HasSelectableContent() bool {
return self.hasSelectableContent
}
// SetHasSelectableContent is for the contexts whose answer isn't fixed and isn't a
// list length either: the main panes, which can only tell by reading the diff they
// have rendered. Whoever sets it re-derives the highlights that follow from it (see
// ContextMgr.UpdateSelectionHighlights).
func (self *BaseContext) SetHasSelectableContent(value bool) {
self.hasSelectableContent = value
}
func (self *BaseContext) GetKey() types.ContextKey {
return self.key
}
@@ -159,6 +153,7 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
self.onQuitFns = nil
self.onDoubleClickFn = nil
self.onClickFn = nil
self.onClickFocusedMainViewFn = nil
self.onRenderToMainFn = nil
}
@@ -180,6 +175,15 @@ func (self *BaseContext) AddOnClickFn(fn func(opts gocui.ViewMouseBindingOpts) e
}
}
func (self *BaseContext) AddOnClickFocusedMainViewFn(fn onClickFocusedMainViewFn) {
if fn != nil {
if self.onClickFocusedMainViewFn != nil {
panic("only one controller is allowed to set an onClickFocusedMainViewFn")
}
self.onClickFocusedMainViewFn = fn
}
}
func (self *BaseContext) GetOnDoubleClick() func() error {
return self.onDoubleClickFn
}
@@ -188,6 +192,10 @@ func (self *BaseContext) GetOnClick() func(opts gocui.ViewMouseBindingOpts) erro
return self.onClickFn
}
func (self *BaseContext) GetOnClickFocusedMainView() onClickFocusedMainViewFn {
return self.onClickFocusedMainViewFn
}
func (self *BaseContext) AddOnRenderToMainFn(fn func()) {
if fn != nil {
if self.onRenderToMainFn != nil {
+3 -8
View File
@@ -19,16 +19,11 @@ type CommitFilesContext struct {
}
var (
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
_ types.DiffMainViewContext = (*CommitFilesContext)(nil)
_ types.IListContext = (*CommitFilesContext)(nil)
_ types.DiffableContext = (*CommitFilesContext)(nil)
_ types.IFilterableContext = (*CommitFilesContext)(nil)
)
func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
+3 -8
View File
@@ -31,16 +31,11 @@ type commitDropIndicator struct {
}
var (
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
_ types.IListContext = (*LocalCommitsContext)(nil)
_ types.DiffableContext = (*LocalCommitsContext)(nil)
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
)
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
+7 -62
View File
@@ -8,62 +8,9 @@ import (
type MainContext struct {
*SimpleContext
*SearchTrait
diffSelect types.DiffSelectState
// dragAnchorViewLine is the view line a mouse-down landed on, remembered so that a
// drag that follows can anchor its range there. The click may have selected a whole
// hunk, whose range anchor is the block's far end, so the clicked line can't be
// read back from the view.
dragAnchorViewLine int
// selectableContentRenderKey names the render whose content HasSelectableContent
// was worked out from. What there is to select is a property of the content, so an
// answer about the content of another render says nothing about this one.
selectableContentRenderKey string
}
var (
_ types.ISearchableContext = (*MainContext)(nil)
_ types.DiffPaneContext = (*MainContext)(nil)
)
// DiffSelectState returns the focused main view's selection mode state, for the
// controllers to read and mutate directly.
func (self *MainContext) DiffSelectState() *types.DiffSelectState {
return &self.diffSelect
}
// ResetDiffSelectMode returns the pane's selection to the default mode — a single
// line, no range — for whenever it is established from scratch rather than moved. The
// view's range anchor is cleared too, so the next render highlights the cursor line
// only.
func (self *MainContext) ResetDiffSelectMode() {
self.diffSelect.Mode = types.DiffSelectModeLine
self.diffSelect.RangeIsSticky = false
self.diffSelect.UserEnabledHunkMode = false
self.GetView().CancelRangeSelect()
}
// SetDragAnchorViewLine records the view line a mouse-down landed on, so that a drag
// that follows can anchor its range there (see dragAnchorViewLine).
func (self *MainContext) SetDragAnchorViewLine(viewLine int) {
self.dragAnchorViewLine = viewLine
}
// DragAnchorViewLine returns the view line the last mouse-down landed on.
func (self *MainContext) DragAnchorViewLine() int {
return self.dragAnchorViewLine
}
// SelectableContentRenderKey returns the render HasSelectableContent describes (see
// selectableContentRenderKey).
func (self *MainContext) SelectableContentRenderKey() string {
return self.selectableContentRenderKey
}
// SetSelectableContentRenderKey records which render HasSelectableContent describes.
func (self *MainContext) SetSelectableContentRenderKey(key string) {
self.selectableContentRenderKey = key
}
var _ types.ISearchableContext = (*MainContext)(nil)
func NewMainContext(
view *gocui.View,
@@ -74,11 +21,12 @@ func NewMainContext(
ctx := &MainContext{
SimpleContext: NewSimpleContext(
NewBaseContext(NewBaseContextOpts{
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
Kind: types.MAIN_CONTEXT,
View: view,
WindowName: windowName,
Key: key,
Focusable: true,
HasSelectableContent: false,
})),
SearchTrait: NewSearchTrait(c),
}
@@ -90,8 +38,5 @@ func (self *MainContext) ModelSearchResults(searchStr string, caseSensitive bool
return nil
}
// When selecting a search result, collapse a range selection (whether sticky or not)
// or a hunk selection to just the matching line.
func (self *MainContext) OnSearchSelect(int) {
self.ResetDiffSelectMode()
}
+2 -7
View File
@@ -14,15 +14,10 @@ type ReflogCommitsContext struct {
}
var (
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
_ types.DiffMainViewContext = (*ReflogCommitsContext)(nil)
_ types.IListContext = (*ReflogCommitsContext)(nil)
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
)
func (self *ReflogCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },
+2 -7
View File
@@ -12,15 +12,10 @@ type StashContext struct {
}
var (
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
_ types.DiffMainViewContext = (*StashContext)(nil)
_ types.IListContext = (*StashContext)(nil)
_ types.DiffableContext = (*StashContext)(nil)
)
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewStashContext(
c *ContextCommon,
) *StashContext {
+3 -8
View File
@@ -21,16 +21,11 @@ type SubCommitsContext struct {
}
var (
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
_ types.IListContext = (*SubCommitsContext)(nil)
_ types.DiffableContext = (*SubCommitsContext)(nil)
_ types.ISearchableContext = (*SubCommitsContext)(nil)
)
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypePatchBuilding
}
func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
+2 -7
View File
@@ -15,15 +15,10 @@ type WorkingTreeContext struct {
}
var (
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
_ types.DiffMainViewContext = (*WorkingTreeContext)(nil)
_ types.IListContext = (*WorkingTreeContext)(nil)
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
)
func (self *WorkingTreeContext) GetDiffMainViewType() types.DiffMainViewType {
return types.DiffMainViewTypeStaging
}
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
+3 -2
View File
@@ -68,7 +68,6 @@ func (gui *Gui) resetHelpersAndControllers() {
searchHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
diffLineHelper := helpers.NewDiffLineHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
helperCommon,
rebaseHelper,
@@ -111,7 +110,6 @@ func (gui *Gui) resetHelpersAndControllers() {
SuspendResume: helpers.NewSuspendResumeHelper(helperCommon),
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,
DiffLine: diffLineHelper,
Repos: reposHelper,
RecordDirectory: recordDirectoryHelper,
Update: helpers.NewUpdateHelper(helperCommon, gui.Updater),
@@ -178,6 +176,7 @@ func (gui *Gui) resetHelpersAndControllers() {
contextLinesController := controllers.NewContextLinesController(common)
renameSimilarityThresholdController := controllers.NewRenameSimilarityThresholdController(common)
verticalScrollControllerFactory := controllers.NewVerticalScrollControllerFactory(common)
viewSelectionControllerFactory := controllers.NewViewSelectionControllerFactory(common)
branchesController := controllers.NewBranchesController(common)
gitFlowController := controllers.NewGitFlowController(common)
@@ -314,11 +313,13 @@ func (gui *Gui) resetHelpersAndControllers() {
controllers.AttachControllers(gui.State.Contexts.Normal,
mainViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.Normal),
viewSelectionControllerFactory.Create(gui.State.Contexts.Normal),
)
controllers.AttachControllers(gui.State.Contexts.NormalSecondary,
secondaryViewController,
verticalScrollControllerFactory.Create(gui.State.Contexts.NormalSecondary),
viewSelectionControllerFactory.Create(gui.State.Contexts.NormalSecondary),
)
controllers.AttachControllers(gui.State.Contexts.Files,
+1
View File
@@ -8,6 +8,7 @@ func AttachControllers(context types.Context, controllers ...types.IController)
context.AddMouseKeybindingsFn(controller.GetMouseKeybindings)
context.AddOnDoubleClickFn(controller.GetOnDoubleClick())
context.AddOnClickFn(controller.GetOnClick())
context.AddOnClickFocusedMainViewFn(controller.GetOnClickFocusedMainView())
context.AddOnRenderToMainFn(controller.GetOnRenderToMain())
context.AddOnFocusFn(controller.GetOnFocus())
context.AddOnFocusLostFn(controller.GetOnFocusLost())
+4
View File
@@ -19,6 +19,10 @@ func (self *baseController) GetOnDoubleClick() func() error {
return nil
}
func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return nil
}
func (self *baseController) GetOnClick() func(opts gocui.ViewMouseBindingOpts) error {
return nil
}
@@ -606,6 +606,16 @@ func (self *CommitFilesController) expandAll() error {
return nil
}
func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
+10
View File
@@ -408,6 +408,16 @@ func (self *FilesController) GetOnDoubleClick() func() error {
})
}
func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
return func(mainViewName string, clickedLineIdx int) error {
node := self.getSelectedItem()
if node != nil && node.File != nil {
return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx})
}
return nil
}
}
// if we are dealing with a status for which there is no key in this map,
// then we won't optimistically render: we'll just let `git status` tell
// us what the new status is.
@@ -1,127 +0,0 @@
package helpers
import (
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type DiffLineHelper struct {
c *HelperCommon
}
func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper {
return &DiffLineHelper{c: c}
}
// GetDiffLineInfo recovers the identity — file, kind, and old/new line number —
// of the diff row at the given (wrapped) view line of the given view. It is the
// seam every consumer of a diff row goes through, so that how we recover that
// identity can change without them noticing.
//
// There are two ways, and which one is used is settled for the rendering as a
// whole (see renderingStatesDiffLines). A diff renderer that speaks the OSC 1717
// protocol states the identity of each line it renders. That is the only way to
// recover it from a rendering that doesn't look like a diff any more (columns, or
// +/- markers replaced by colour), and a row such a renderer says nothing about
// has no identity. Otherwise we parse the view's contents as a unified diff; this
// works for the renderings that keep a diff's structure (no renderer, `git diff
// --color`, a renderer that only colorizes) and fails for the rest.
//
// ok is false when the row's identity can't be recovered, in which case the
// caller must not act on the line at all.
func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
// The cursor and clicks land on a view line, which counts wrapped segments;
// the contents are indexed by unwrapped buffer line.
bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
contents := view.DiffLineContents()
if bufferLineIdx >= len(contents) {
return types.DiffLineInfo{}, false
}
if renderingStatesDiffLines(contents) {
if info, ok := self.diffLineInfoFromRecords(contents[bufferLineIdx].Metadata); ok {
return info, true
}
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx)
if !ok {
return types.DiffLineInfo{}, false
}
return self.diffLineInfo(parsed), true
}
// diffLineInfoFromRecords recovers a row's identity from the records the diff
// renderer stated for it. ok is false when the row carries no record we understand.
//
// A row can carry more than one record, when the rendering puts two diff lines on it
// (a side-by-side row shows a deletion and the addition replacing it); the leftmost
// is the one a reader would call the row's own, so it is the row's identity.
func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) {
if len(metadata) == 0 {
return types.DiffLineInfo{}, false
}
parsed, ok := parseDiffLineMetadata(metadata[0])
if !ok {
return types.DiffLineInfo{}, false
}
return self.diffLineInfo(parsed), true
}
// resolvedDiffLine is one rendered row's recovered identity, plus whether it could
// be recovered at all — the element of the table resolveDiffLines produces.
type resolvedDiffLine struct {
info types.DiffLineInfo
ok bool
}
// resolveDiffLines recovers the identity of every row of a rendered diff in one
// pass, indexed 1:1 with contents. It is the batch form of GetDiffLineInfo, for the
// whole-buffer scans (which change lines are where, which file each row belongs
// to), and reads the rendering the same way: by the renderer's records, or by
// parsing it as a unified diff (see renderingStatesDiffLines). Resolving row by row
// would re-run the buffer parser's whole-section parse once per row — O(n²) on a
// large single-file diff — so the buffer parser runs once for the whole buffer.
func (self *DiffLineHelper) resolveDiffLines(contents []gocui.DiffLineContent) []resolvedDiffLine {
resolved := make([]resolvedDiffLine, len(contents))
if renderingStatesDiffLines(contents) {
for i, content := range contents {
if info, ok := self.diffLineInfoFromRecords(content.Metadata); ok {
resolved[i] = resolvedDiffLine{info, true}
}
}
return resolved
}
for i, parsed := range parseAllDiffLinesFromBuffer(diffLineTexts(contents)) {
if parsed.ok {
resolved[i] = resolvedDiffLine{self.diffLineInfo(parsed.parsed), true}
}
}
return resolved
}
// diffLineInfo turns a parser's result into the absolute-path identity consumers
// work with. The path arrives repo-relative from the diff header, but a renderer
// states it however it likes, absolute paths included.
func (self *DiffLineHelper) diffLineInfo(parsed parsedDiffLine) types.DiffLineInfo {
path := parsed.Path
if !filepath.IsAbs(path) {
path = filepath.Join(self.c.Git().RepoPaths.WorktreePath(), path)
}
return types.DiffLineInfo{
Path: path,
Type: parsed.Type,
NewLine: parsed.NewLine,
OldLine: parsed.OldLine,
}
}
@@ -1,412 +0,0 @@
package helpers
import (
"regexp"
"slices"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
// unified diff.
const diffFilePrefix = "diff --git "
// submodulePrefix opens the line a submodule's section of a diff starts with, which
// stands in for the "diff --git" header a file of the repo gets.
const submodulePrefix = "Submodule "
// submoduleSectionPattern matches that line and captures the submodule's path. git
// writes one of two kinds: the commit the submodule is checked out at has moved
// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is
// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in
// place of the colon where the two commits can't both be read), or its working tree
// is dirty ("Submodule sub contains untracked content").
//
// The path is captured greedily: git writes it unquoted, so a path that itself ends
// in something reading like a range of commits is told apart by taking the last such
// range on the line.
var submoduleSectionPattern = regexp.MustCompile(
`^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`)
// parsedDiffLine is what the parser recovers about a row of a rendered diff.
// Path is the path as the diff header spells it, i.e. relative to the repo root;
// the caller turns it into the absolute path of types.DiffLineInfo.
type parsedDiffLine struct {
Path string
Type types.DiffLineType
NewLine int
OldLine int
}
// bufferLineParse is the parser's result for one buffer line: the recovered
// identity, and whether the line could be resolved at all (false for a line in
// an unparseable section, or outside any file section).
type bufferLineParse struct {
parsed parsedDiffLine
ok bool
}
// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by
// parsing the view's decolorized contents.
//
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
// resolve. A commit's diff spans several files, so we isolate the file section
// containing targetIdx and parse just that one (see parseFileSection). Use this
// for a single line, e.g. the one under the cursor; to resolve every line of a
// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once.
//
// ok is false when the buffer isn't a parseable unified diff at targetIdx,
// because the diff renderer restructured it, so that the caller can fall back.
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
if targetIdx < 0 || targetIdx >= len(bufferLines) {
return parsedDiffLine{}, false
}
start, end := fileSectionBounds(bufferLines, targetIdx)
if start == -1 {
return parsedDiffLine{}, false
}
r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start]
return r.parsed, r.ok
}
// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file)
// diff buffer in one pass, parsing each file section exactly once. It is the
// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer:
// resolving line by line would re-parse a section once per line of it — O(n²) on
// a large single-file diff — whereas this is O(n). The result is indexed 1:1
// with bufferLines; a line in an unparseable section, or above the first one, is
// left ok=false.
func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse {
result := make([]bufferLineParse, len(bufferLines))
for i := 0; i < len(bufferLines); {
if !startsFileSection(bufferLines[i]) {
i++ // in no file section; leave it unresolved
continue
}
end := fileSectionEnd(bufferLines, i)
copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines)))
i = end
}
return result
}
// diffLineTexts extracts the text of each rendered row — the material the buffer
// parser works on.
func diffLineTexts(contents []gocui.DiffLineContent) []string {
texts := make([]string, len(contents))
for i, content := range contents {
texts[i] = content.Text
}
return texts
}
// renderingStatesDiffLines reports whether the renderer stated, for at least one row
// of the rendering, which diff line it shows. The version-only record a renderer
// announces the protocol with names no line, and doesn't count.
//
// The answer settles how the whole rendering is read. A renderer that states its
// lines lays the diff out as it likes, so its text is no unified diff and must not be
// parsed as one, not even for the rows it says nothing about. Such a row can read
// like a diff header when the file being diffed is itself a diff; the parser would
// take that for the start of a file section and place every untagged row below it
// in a file the diff doesn't have. So the rows a renderer leaves untagged (dividers,
// padding) have no identity, as the protocol has it. A rendering without any record
// is a diff that describes itself, and is parsed as one.
func renderingStatesDiffLines(contents []gocui.DiffLineContent) bool {
return slices.ContainsFunc(contents, func(content gocui.DiffLineContent) bool {
return slices.ContainsFunc(content.Metadata, func(record string) bool {
_, ok := parseDiffLineMetadata(record)
return ok
})
})
}
// fileSectionBounds returns the half-open range [start, end) of the file section
// containing targetIdx: the nearest line starting a section at or above it, up to
// where that section ends. start is -1 when targetIdx is above the first file
// section, or below the end of the last one that begins above it.
func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) {
for start = targetIdx; start >= 0; start-- {
if !startsFileSection(bufferLines[start]) {
continue
}
if end = fileSectionEnd(bufferLines, start); targetIdx < end {
return start, end
}
return -1, -1
}
return -1, -1
}
// fileSectionEnd returns the line the file section beginning at start ends before.
//
// A file's section runs to the next one, since every line between them is part of
// its diff. A submodule's runs only as far as what git writes for it — the line
// naming it, and the log of the commits it moved over — because the lines after
// that need not belong to any section at all. A diff renderer's output has no
// "diff --git" line to stop at, and the rows it puts between one file and the next
// belong to neither.
func fileSectionEnd(bufferLines []string, start int) int {
if submodulePath(bufferLines[start]) != "" {
end := start + 1
for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) {
end++
}
return end
}
for i := start + 1; i < len(bufferLines); i++ {
if startsFileSection(bufferLines[i]) {
return i
}
}
return len(bufferLines)
}
// startsFileSection reports whether the line opens a section of a diff: git's header
// for a file of the repo, or the line a submodule's section begins with.
func startsFileSection(line string) bool {
return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != ""
}
// isSubmoduleLogLine reports whether the line is one of the commits git lists under
// a submodule's header, which it writes as two spaces, the direction the commit was
// moved in, and the commit's subject.
func isSubmoduleLogLine(line string) bool {
return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ")
}
// submodulePath returns the submodule whose section the given line opens, and "" for
// every other line.
//
// A submodule gets no "diff --git" header and no hunks: git states which commits it
// moved between and lists them, so that one line is all there is to take the path
// from. The prefix is tested first so that the pattern is run over next to no lines
// of a diff.
func submodulePath(line string) string {
if !strings.HasPrefix(line, submodulePrefix) {
return ""
}
if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil {
return match[1]
}
return ""
}
// parseFileSection parses one file's diff section (fileLines, starting at the line
// that opens it) a single time and returns the identity of each of its
// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the
// section's buffer lines, so the type and the old/new line numbers fall out of
// the patch arithmetic. A submodule's section has no hunks at all, so every row of
// it comes out as a header of the submodule, which is what they are: what git states
// there is which commits it moved between, not lines of a file.
//
// Every line is left ok=false when the section has no
// recoverable path or isn't a well-formed unified diff — the rendering
// restructured it, and acting on a mis-parse would land us on the wrong line, so
// the caller should fall back.
//
// endsTheBuffer says the section runs to the end of what we were given. That is
// where a diff we have only part of breaks off. A long one is read a screenful
// at a time and the rest as the user scrolls, so its last hunk holds fewer lines
// than its header declares until the reading is done. Insisting on the whole
// hunk there would leave every line of the file unresolved while the diff is the
// one on screen, so a section in that position is held to what has arrived.
func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse {
result := make([]bufferLineParse, len(fileLines))
relPath := pathFromDiffHeader(fileLines)
if relPath == "" {
return result
}
p := patch.Parse(strings.Join(fileLines, "\n"))
isWellFormed := p.IsWellFormed
if endsTheBuffer {
isWellFormed = p.IsWellFormedSoFar
}
if !isWellFormed() {
return result
}
patchLines := p.Lines()
for i := range fileLines {
if i >= len(patchLines) {
break
}
parsed := parsedDiffLine{
Path: relPath,
Type: diffLineTypeForKind(patchLines[i].Kind),
NewLine: p.LineNumberOfLine(i),
}
if parsed.Type == types.DiffLineDeleted {
parsed.OldLine = p.OldLineNumberOfLine(i)
}
result[i] = bufferLineParse{parsed, true}
}
return result
}
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
switch kind {
case patch.PATCH_HEADER:
return types.DiffLineFileHeader
case patch.HUNK_HEADER:
return types.DiffLineHunkHeader
case patch.ADDITION:
return types.DiffLineAdded
case patch.DELETION:
return types.DiffLineDeleted
case patch.CONTEXT:
return types.DiffLineContext
default:
return types.DiffLineOther
}
}
// pathFromDiffHeader extracts the new-file path of a single diff section. A
// submodule's section states its path in the line it opens with. For a file of the
// repo the path comes from the "+++ b/<path>" line, falling back to "--- a/<path>"
// when the new path is /dev/null (a deleted file), and to the "diff --git" line when
// there are no such lines at all (a pure rename, which has no hunks).
func pathFromDiffHeader(fileLines []string) string {
if path := submodulePath(fileLines[0]); path != "" {
return path
}
var oldPath, newPath string
for _, line := range fileLines {
if strings.HasPrefix(line, "@@") {
break // past the header
}
switch {
case strings.HasPrefix(line, "+++ "):
newPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "+++ "))
case strings.HasPrefix(line, "--- "):
oldPath = pathFromDiffHeaderField(strings.TrimPrefix(line, "--- "))
}
}
if newPath != "" && newPath != "/dev/null" {
return newPath
}
if oldPath != "" && oldPath != "/dev/null" {
return oldPath
}
return pathFromDiffGitLine(fileLines[0])
}
// pathFromDiffHeaderField decodes one path field of a diff header — the part
// after "--- " or "+++ ", or one of the two paths on the "diff --git" line —
// into the repo-relative path it names.
//
// git spells such a field in three ways: plain; terminated by a tab, when the
// path contains a space; or C-quoted as a whole, when the path contains
// characters git won't print raw — which, with core.quotePath enabled (the
// default), includes every non-ASCII byte, so `café` arrives as
// `"b/caf\303\251"`. The quoting is Go's string syntax, octal escapes included,
// so strconv decodes it for us.
//
// Returns "" for a quoted field we can't decode: better to resolve nothing than
// to point a consumer at a path that doesn't exist.
func pathFromDiffHeaderField(field string) string {
field = strings.TrimSuffix(field, "\t")
if strings.HasPrefix(field, `"`) {
unquoted, err := strconv.Unquote(field)
if err != nil {
return ""
}
field = unquoted
}
return stripDiffPathPrefix(field)
}
// stripDiffPathPrefix removes the a/ or b/ prefix git puts on the paths in a
// diff header. We ask git for these prefixes explicitly (diff.noprefix=false),
// so they are always there.
func stripDiffPathPrefix(path string) string {
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
return path[2:]
}
return path
}
// parseDiffLineMetadata parses the payload of an OSC 1717 record, in which a
// diff renderer states which line of which file it is rendering. The v1 payload
// is positional and ';'-delimited:
//
// version;type;new-line;old-line;file
//
// The file comes last so that it may itself contain a ';'. The old-file line is
// empty unless the line is a deletion, the only kind that needs it, and the
// new-file line is empty on a file header, the one kind that has no line.
//
// ok is false for a payload of an unknown version or shape, so that the caller
// can fall back to reading the rendered text.
func parseDiffLineMetadata(payload string) (parsedDiffLine, bool) {
fields := strings.SplitN(payload, ";", 5)
if len(fields) < 5 || fields[0] != "1" {
return parsedDiffLine{}, false
}
lineType, ok := diffLineTypeFromMetadata(fields[1])
if !ok {
return parsedDiffLine{}, false
}
newLine := 0
if fields[2] != "" {
var err error
if newLine, err = strconv.Atoi(fields[2]); err != nil {
return parsedDiffLine{}, false
}
} else if lineType != types.DiffLineFileHeader {
return parsedDiffLine{}, false
}
oldLine := 0
if fields[3] != "" {
var err error
if oldLine, err = strconv.Atoi(fields[3]); err != nil {
return parsedDiffLine{}, false
}
}
return parsedDiffLine{Path: fields[4], Type: lineType, NewLine: newLine, OldLine: oldLine}, true
}
func diffLineTypeFromMetadata(typeField string) (types.DiffLineType, bool) {
switch typeField {
case "c":
return types.DiffLineContext, true
case "a":
return types.DiffLineAdded, true
case "d":
return types.DiffLineDeleted, true
case "f":
return types.DiffLineFileHeader, true
case "h":
return types.DiffLineHunkHeader, true
default:
return types.DiffLineOther, false
}
}
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
// line, where the two paths are separated by a space and either may be quoted.
// A path containing " b/" (or ` "b/`) would defeat this, but the +++/--- lines
// are unambiguous and we only get here when they are absent.
func pathFromDiffGitLine(line string) string {
rest := strings.TrimPrefix(line, diffFilePrefix)
if idx := strings.LastIndex(rest, ` "b/`); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
return pathFromDiffHeaderField(rest[idx+1:])
}
return ""
}
@@ -1,422 +0,0 @@
package helpers
import (
"slices"
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/stretchr/testify/assert"
)
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
// two consecutive deletions (grape, pear) that share a new-file line number;
// file2 has two consecutive additions.
const twoFileDiff = `diff --git a/file1.go b/file1.go
index 1111111..2222222 100644
--- a/file1.go
+++ b/file1.go
@@ -1,4 +1,2 @@
apple
-grape
-pear
lemon
diff --git a/dir/file2.go b/dir/file2.go
index 3333333..4444444 100644
--- a/dir/file2.go
+++ b/dir/file2.go
@@ -10,2 +9,4 @@ func foo() {
ctx
+added1
+added2
ctx2`
func TestParseDiffLineFromBuffer(t *testing.T) {
bufferLines := strings.Split(twoFileDiff, "\n")
scenarios := []struct {
name string
targetIdx int
expected parsedDiffLine
expectOk bool
}{
{"file header", 0, parsedDiffLine{Path: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
{"hunk header", 4, parsedDiffLine{Path: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
{"context line", 5, parsedDiffLine{Path: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
// The two deletions share new-file line 2 but have distinct old-file lines.
{"first deletion", 6, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", 7, parsedDiffLine{Path: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// The second file: its path comes from the second "diff --git" section,
// and its additions get distinct new-file line numbers.
{"first addition", 15, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
{"second addition", 16, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
{"out of range", 999, parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
func TestParseDiffLineFromBufferRename(t *testing.T) {
// A rename with no content change has no hunks and no +++/--- lines, so the
// path has to come from the "diff --git" line; a rename with a content
// change has them, and they carry the new path.
pureRename := strings.Split(`diff --git a/old.go b/new.go
similarity index 100%
rename from old.go
rename to new.go`, "\n")
result, ok := parseDiffLineFromBuffer(pureRename, 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
renameWithModification := strings.Split(`diff --git a/old.go b/new.go
similarity index 62%
rename from old.go
rename to new.go
index 1111111..2222222 100644
--- a/old.go
+++ b/new.go
@@ -1,2 +1,2 @@
apple
-grape
+kiwi`, "\n")
result, ok = parseDiffLineFromBuffer(renameWithModification, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
}
func TestParseDiffLineFromBufferDeletedFile(t *testing.T) {
// The new path is /dev/null, so the identity comes from the old path.
deletedFile := strings.Split(`diff --git a/gone.go b/gone.go
deleted file mode 100644
index 1111111..0000000
--- a/gone.go
+++ /dev/null
@@ -1,2 +0,0 @@
-apple
-grape`, "\n")
result, ok := parseDiffLineFromBuffer(deletedFile, 7)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result)
}
func TestParseDiffLineFromBufferSubmodule(t *testing.T) {
// A submodule has no "diff --git" header and no hunks: git opens its section with
// the commits it moved between and lists them below. So the section ends the one
// above it, and every row of it belongs to the submodule as a whole.
withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt
index 1111111..2222222 100644
--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,3 @@
hello
world
+world
Submodule modules/xyz a32f27c..2d9f921:
> bump the thing`, "\n")
for _, targetIdx := range []int{8, 9} {
result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx)
assert.True(t, ok)
assert.Equal(t,
parsedDiffLine{Path: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1},
result, "line %d", targetIdx)
}
result, ok := parseDiffLineFromBuffer(withSubmodule, 7)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result)
}
func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) {
// A diff renderer passes the lines git writes for a submodule through as they
// are, while printing nothing below them that a section could end at. The
// section has to end where what git writes for the submodule ends all the same:
// the rows below belong to the other files of the diff, and can only be placed
// by the records the renderer states for them.
rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921:
> bump the thing
products/a.txt
one
two`, "\n")
all := parseAllDiffLinesFromBuffer(rendered)
assert.Equal(t, "modules/xyz", all[0].parsed.Path)
assert.Equal(t, "modules/xyz", all[1].parsed.Path)
for i := 2; i < len(rendered); i++ {
assert.False(t, all[i].ok, "line %d: %q", i, rendered[i])
}
}
func TestSubmodulePath(t *testing.T) {
scenarios := []struct {
name string
line string
expected string
}{
{"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"},
{"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"},
{"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"},
{"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"},
{"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"},
{"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"},
{"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"},
{"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"},
// git writes the path unquoted, so one with a space in it, or one ending in
// something that reads like a range of commits, is told apart by matching the
// last range on the line.
{"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"},
{"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"},
// A line of a file that reads like one of these is indented by the column the
// diff states the line's side in, so it cannot be mistaken for one.
{"a line of a file", " Submodule modules/xyz contains modified content", ""},
{"something else entirely", "Submodule support was added", ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, submodulePath(s.line))
})
}
}
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
// A rendering with no "diff --git" line can't be parsed, so the caller falls
// back rather than acting on the line.
bufferLines := []string{"some", "lines", "that", "are not a diff"}
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
assert.False(t, ok)
}
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
// A diff renderer that moves the line numbers into a gutter keeps the diff
// and hunk headers but pushes the +/- markers off the start of each body
// line, so every line reads as context. The body no longer matches the hunk
// header, so we refuse to parse rather than return a confident mis-parse.
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
index 1111111..2222222 100644
--- a/file1.txt
+++ b/file1.txt
@@ -1,5 +1,3 @@
1 ⋮ 1 │ apple
2 ⋮ │-grape
3 ⋮ │-pear
4 ⋮ 2 │ lemon
5 ⋮ 3 │ mango`, "\n")
_, ok := parseDiffLineFromBuffer(mangled, 6)
assert.False(t, ok)
}
func TestParseDiffLineFromBufferReadInPart(t *testing.T) {
lines := strings.Split(twoFileDiff, "\n")
// A long diff is read a screenful at a time, so the buffer breaks off part way
// through a hunk. The lines that did arrive are resolved all the same, since
// holding out for the whole hunk would leave the diff on screen with nothing to
// act on.
cutShort := lines[:len(lines)-1]
result, ok := parseDiffLineFromBuffer(cutShort, 15)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result)
// Only the section the buffer breaks off in is read that way. One that another
// section follows is all there, so a hunk short of what its header declares means
// the rendering restructured the diff, and none of it is resolved.
shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...)
_, ok = parseDiffLineFromBuffer(shortFirstSection, 5)
assert.False(t, ok)
}
func TestPathFromDiffHeaderField(t *testing.T) {
scenarios := []struct {
name string
field string
expected string
}{
{"new side", "b/file.go", "file.go"},
{"old side", "a/file.go", "file.go"},
{"a missing file", "/dev/null", "/dev/null"},
// git terminates the field with a tab when the path has a space in it.
{"path with a space", "b/with space.go\t", "with space.go"},
// With core.quotePath enabled (the default) every non-ASCII byte is
// escaped, and the field is quoted as a whole, prefix included.
{"non-ASCII path", `"b/caf\303\251.go"`, "café.go"},
{"non-ASCII path with a space", "\"b/caf\\303\\251 x.go\"\t", "café x.go"},
{"path with a double quote", `"b/we\"ird.go"`, `we"ird.go`},
{"path with a backslash", `"b/back\\slash.go"`, `back\slash.go`},
{"path with a tab", `"b/tab\there.go"`, "tab\there.go"},
{"undecodable", `"b/unterminated`, ""},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, pathFromDiffHeaderField(s.field))
})
}
}
func TestParseDiffLineFromBufferQuotedPath(t *testing.T) {
// A rename of a file whose name needs quoting, with a content change: the
// path is quoted on the "diff --git" line and on both of the +++/--- lines.
renamed := []string{
`diff --git "a/caf\303\251 old.go" "b/caf\303\251 new.go"`,
"similarity index 62%",
`rename from "caf\303\251 old.go"`,
`rename to "caf\303\251 new.go"`,
"index 1111111..2222222 100644",
"--- \"a/caf\\303\\251 old.go\"\t",
"+++ \"b/caf\\303\\251 new.go\"\t",
"@@ -1,2 +1,2 @@",
" apple",
"-grape",
"+kiwi",
}
result, ok := parseDiffLineFromBuffer(renamed, 10)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineAdded, NewLine: 2}, result)
// The same rename without a content change has no +++/--- lines, so the path
// comes from the "diff --git" line, where both paths are quoted.
result, ok = parseDiffLineFromBuffer(renamed[:4], 2)
assert.True(t, ok)
assert.Equal(t, parsedDiffLine{Path: "café new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result)
}
func TestParseAllDiffLinesFromBuffer(t *testing.T) {
// Some decoration above the diff, which belongs to no file section: a commit
// message and a diffstat, as `git show` renders them.
bufferLines := append(
[]string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""},
strings.Split(twoFileDiff, "\n")...,
)
all := parseAllDiffLinesFromBuffer(bufferLines)
// The batch parse resolves each file section once, and has to agree with
// resolving the lines one at a time.
assert.Len(t, all, len(bufferLines))
for i := range bufferLines {
parsed, ok := parseDiffLineFromBuffer(bufferLines, i)
assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i])
}
// The lines above the first file section are left unresolved.
for i := range 6 {
assert.False(t, all[i].ok)
}
assert.True(t, all[6].ok)
}
func TestParseDiffLineMetadata(t *testing.T) {
scenarios := []struct {
name string
payload string
expected parsedDiffLine
expectOk bool
}{
{"context", "1;c;1;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineContext, NewLine: 1}, true},
{"added", "1;a;3;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineAdded, NewLine: 3}, true},
// A deletion carries both numbers; two consecutive deletions share the
// new-file line and differ only in the old-file one.
{"first deletion", "1;d;2;2;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
{"second deletion", "1;d;2;3;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
// A whole-file deletion has new-file position 0 and the old path.
{"deleted file", "1;d;0;1;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 1}, true},
// The path is the last field, so a ';' within it survives.
{"path with semicolon", "1;c;5;;weird;name.txt", parsedDiffLine{Path: "weird;name.txt", Type: types.DiffLineContext, NewLine: 5}, true},
// A renderer may state the path absolutely; the parser keeps it verbatim
// and leaves resolving it to the caller.
{"absolute path", "1;a;7;;/abs/foo.txt", parsedDiffLine{Path: "/abs/foo.txt", Type: types.DiffLineAdded, NewLine: 7}, true},
// A file header has no line number; a hunk header carries the new-file
// line of the hunk's first line (0 for a whole-file deletion, mirroring
// `@@ -1,N +0,0 @@`).
{"file header", "1;f;;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader}, true},
{"hunk header", "1;h;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineHunkHeader, NewLine: 10}, true},
{"hunk header of a deleted file", "1;h;0;;gone.txt", parsedDiffLine{Path: "gone.txt", Type: types.DiffLineHunkHeader, NewLine: 0}, true},
// A file header's line number is always empty, but a renderer that fills
// it in anyway is taken at its word rather than rejected.
{"file header with a line number", "1;f;10;;foo.txt", parsedDiffLine{Path: "foo.txt", Type: types.DiffLineFileHeader, NewLine: 10}, true},
{"unknown version", "2;c;1;;foo.txt", parsedDiffLine{}, false},
{"unknown type", "1;x;1;;foo.txt", parsedDiffLine{}, false},
{"too few fields", "1;c;1", parsedDiffLine{}, false},
{"non-numeric new-line", "1;c;x;;foo.txt", parsedDiffLine{}, false},
{"non-numeric old-line", "1;d;2;y;foo.txt", parsedDiffLine{}, false},
// Only a file header may omit the new-file line; on any other kind the
// record is malformed, and rejecting it falls the row back to the diff
// text rather than acting on a line number we don't have.
{"empty new-line on a content line", "1;c;;;foo.txt", parsedDiffLine{}, false},
{"empty new-line on a hunk header", "1;h;;;foo.txt", parsedDiffLine{}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result, ok := parseDiffLineMetadata(s.payload)
assert.Equal(t, s.expectOk, ok)
if s.expectOk {
assert.Equal(t, s.expected, result)
}
})
}
}
func TestRenderingStatesDiffLines(t *testing.T) {
row := func(text string, records ...string) gocui.DiffLineContent {
return gocui.DiffLineContent{Text: text, Metadata: records}
}
scenarios := []struct {
name string
contents []gocui.DiffLineContent
expected bool
}{
{
name: "a rendering without records is read as a diff",
contents: []gocui.DiffLineContent{row("diff --git a/foo.txt b/foo.txt"), row("+one")},
expected: false,
},
{
name: "a record on any row makes the records the source",
contents: []gocui.DiffLineContent{row("foo.txt"), row("one", "1;c;1;;foo.txt"), row("")},
expected: true,
},
{
// A renderer announces the protocol with a record that names no line; a
// rendering with nothing but that one says nothing about its rows.
name: "the version-only handshake record doesn't count",
contents: []gocui.DiffLineContent{row("foo.txt", "1"), row("one")},
expected: false,
},
{
name: "records of a version we don't understand don't count",
contents: []gocui.DiffLineContent{row("one", "2;c;1;;foo.txt")},
expected: false,
},
{
name: "an empty rendering states nothing",
contents: nil,
expected: false,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, renderingStatesDiffLines(s.contents))
})
}
}
@@ -1,359 +0,0 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// The questions a diff view can be asked about what it is showing — where the change
// lines are, which block or file a row belongs to — answered in the view-line terms a
// cursor and a click speak. They are all built on the identities recovered in
// diff_line_helper.go, which is where the answering stops and the recovering starts.
// changeLines resolves view's rendered diff to one flag per buffer line: whether
// that row is a change line (an addition or a deletion), as opposed to context, a
// header, or a row whose identity couldn't be recovered. Those are the rows a
// selection is anchored on and navigation moves between.
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
resolved := self.resolveDiffLines(view.DiffLineContents())
isChange := make([]bool, len(resolved))
for i, r := range resolved {
isChange[i] = r.ok && r.info.IsChange()
}
return isChange
}
// FirstChangeLineInView returns the view line of the first change line on screen. It
// is where the selection goes when the main view is focused by keyboard: focusing a
// diff you are reading points at something in it without moving it, so the search
// stops at the bottom of the viewport rather than going after a change further down.
// ok is false when the viewport holds no change line — scrolled into a long stretch
// of context, or past the last change.
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] {
return view.ViewLineForBufferLine(i)
}
}
return 0, false
}
// FirstChangeBlockInView returns the view line of the first change block on screen:
// the first one that *begins* in the viewport, and failing that the one that reaches
// into the viewport from above, whose start is off screen. Hunk mode wants that order
// for the block it offers up on focus: preferably a block whose beginning the user can
// see, rather than the tail of one they have scrolled past the start of. The block
// bleeding in from above is kept as the answer for a change too long to fit on screen,
// where there is no other. ok is false when the viewport shows no change line.
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
top, bottom, ok := visibleBufferLines(view)
if !ok {
return 0, false
}
isChange := self.changeLines(view)
for i := top; i <= min(bottom, len(isChange)-1); i++ {
if isChange[i] && (i == 0 || !isChange[i-1]) {
return view.ViewLineForBufferLine(i)
}
}
// A block covering the top line is one that began above it: nothing else can put a
// change there once no block starts on screen.
if top < len(isChange) && isChange[top] {
return view.ViewLineForBufferLine(top)
}
return 0, false
}
// visibleBufferLines returns the first and last line of view's content that the
// viewport shows any part of, for the queries that only care about what the user can
// see. The last line is the one at the bottom edge, or the content's last when the
// content ends above it. ok is false for a view showing no content at all.
func visibleBufferLines(view *gocui.View) (int, int, bool) {
top, ok := view.BufferLineForViewLine(view.OriginY())
if !ok {
return 0, 0, false
}
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
bottom, ok := view.BufferLineForViewLine(lastVisible)
if !ok {
return top, top, true
}
return top, bottom, true
}
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
// all, i.e. whether there is anything to select. It is false over a non-diff
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
// which are the cases where the focused main view shows no selection.
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
return lo.Contains(self.changeLines(view), true)
}
// IsChangeLine reports whether the given view line of view's rendered diff is a
// change line rather than context, a header, or an unresolvable row — i.e. whether
// pointing at it points at something a patch could be built from.
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
return ok && info.IsChange()
}
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
// is shown as one solid block of changes — every row of its diff a change of the same
// kind, no context — which is what a newly added or deleted file looks like. That is
// the case where widening the selection to the change block would select the file
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
//
// It says false while the diff is still being read in, since the rows that would
// answer otherwise — a context line, a change of the other kind — may not have
// arrived yet. That errs towards hunk mode, which is what the user asked for.
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
return false
}
anchor, ok := view.BufferLineForViewLine(changeViewLine)
if !ok {
return false
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) || !resolved[anchor].ok {
return false
}
// The question is per file: a commit's diff may hold a newly added file next to an
// edited one.
path := resolved[anchor].info.Path
kind := resolved[anchor].info.Type
for _, row := range resolved {
if !row.ok || row.info.Path != path {
continue
}
if row.info.Type == types.DiffLineContext {
return false
}
if row.info.IsChange() && row.info.Type != kind {
return false
}
}
return true
}
// ChangeBlockBounds returns the inclusive view-line range of the change block to
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
// single git @@ hunk may hold several. When the anchor is context, the block used is
// the first at or below it, or — with nothing below, the cursor sitting past the last
// change — the nearest above, so that hunk mode always has a block to select. ok is
// false only when the diff holds no change line at all.
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, 0, false
}
isChange := self.changeLines(view)
start := anchor
for start < len(isChange) && !isChange[start] {
start++
}
if start >= len(isChange) {
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
}
if start < 0 {
return 0, 0, false
}
}
end := start
for start > 0 && isChange[start-1] {
start--
}
for end < len(isChange)-1 && isChange[end+1] {
end++
}
startView, startOk := view.ViewLineForBufferLine(start)
// The block's last line goes to its last view line, so that a line the view
// wrapped is highlighted to its end rather than only where it begins.
endView, endOk := view.LastViewLineForBufferLine(end)
if !startOk || !endOk {
return 0, 0, false
}
return startView, endView, true
}
// SelectedHunkBounds returns the change block selected in hunk mode. The range
// anchor stays on the block's far end when a click moves the cursor before its
// handler runs, so it still identifies the selected block.
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
anchor := view.RangeSelectStartY()
if anchor < 0 {
return 0, 0, false
}
return self.ChangeBlockBounds(view, anchor)
}
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
// of the next block, forward=false the start of the previous one — from mid-block that
// means the previous block, rather than the one we are in. ok is false when there's no
// further block, so the caller leaves the view where it is.
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
target, ok := changeBlockStart(self.changeLines(view), anchor, forward)
if !ok {
return 0, false
}
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.
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
if from < 0 || from >= len(isChange) {
return 0, false
}
if forward {
i := from
for i < len(isChange) && isChange[i] { // leave the current block
i++
}
for i < len(isChange) && !isChange[i] { // skip the separating context
i++
}
if i == len(isChange) {
return 0, false
}
return i, true
}
i := from
for i >= 0 && isChange[i] { // leave the current block
i--
}
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
i--
}
if i < 0 {
return 0, false
}
for i > 0 && isChange[i-1] { // walk back to that block's first line
i--
}
return i, true
}
@@ -1,104 +0,0 @@
package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChangeBlockStart(t *testing.T) {
// A diff with three change blocks separated by context:
// 0 file header 1 hunk header 2 context
// 3 + 4 + (block A)
// 5 context
// 6 - (block B)
// 7 context
// 8 + (block C)
isChange := []bool{false, false, false, true, true, false, true, false, true}
scenarios := []struct {
name string
from int
forward bool
expected int
found bool
}{
{"forward from a header lands on the first block", 0, true, 3, true},
{"forward from separating context lands on the next block", 5, true, 6, true},
{"forward from the start of a block skips to the next", 3, true, 6, true},
{"forward from inside a block skips the rest of it", 4, true, 6, true},
{"forward from the last block finds nothing", 8, true, 0, false},
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
{"backward from inside the first block finds nothing", 4, false, 0, false},
{"backward from the first block's start finds nothing", 3, false, 0, false},
{"backward from context lands on the preceding block's start", 7, false, 6, true},
{"an anchor past the end finds nothing", 9, true, 0, false},
{"a negative anchor finds nothing", -1, true, 0, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := changeBlockStart(isChange, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
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)
}
})
}
}
-2
View File
@@ -39,7 +39,6 @@ type Helpers struct {
Snake *SnakeHelper
// lives in context package because our contexts need it to render to main
Diff *DiffHelper
DiffLine *DiffLineHelper
Repos *ReposHelper
RecordDirectory *RecordDirectoryHelper
Update *UpdateHelper
@@ -77,7 +76,6 @@ func NewStubHelpers() *Helpers {
Commits: &CommitsHelper{},
Snake: &SnakeHelper{},
Diff: &DiffHelper{},
DiffLine: &DiffLineHelper{},
Repos: &ReposHelper{},
RecordDirectory: &RecordDirectoryHelper{},
Update: &UpdateHelper{},
+10 -771
View File
@@ -3,9 +3,7 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
type MainViewController struct {
@@ -14,9 +12,6 @@ type MainViewController struct {
context *context.MainContext
otherContext *context.MainContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
var _ types.IController = &MainViewController{}
@@ -26,19 +21,12 @@ func NewMainViewController(
context *context.MainContext,
otherContext *context.MainContext,
) *MainViewController {
controller := &MainViewController{
return &MainViewController{
baseController: baseController{},
c: c,
context: context,
otherContext: otherContext,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
c.HelperCommon,
context,
controller.canDragAutoscroll,
controller.handleDragAutoscroll,
)
return controller
}
func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
@@ -50,68 +38,10 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Tooltip: self.c.Tr.ToggleStagingViewTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk),
Handler: self.toggleSelectHunk,
DescriptionFunc: self.diffSelectionDescription(func() string {
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.SelectHunk
}),
Description: self.c.Tr.ToggleSelectHunk,
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.ToggleSelectHunkTooltip,
DisplayOnScreen: true,
},
{
Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect),
Handler: self.toggleRangeSelect,
Description: self.c.Tr.ToggleRangeSelect,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.ToggleRangeSelect),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Keys: opts.GetKeys(opts.Config.Universal.Edit),
Handler: self.editLine,
Description: self.c.Tr.EditFile,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.EditFile),
GetDisabledReason: self.diffSelectionDisabledReason,
Tooltip: self.c.Tr.EditFileTooltip,
},
{
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,
Description: self.c.Tr.ExitFocusedMainView,
DescriptionFunc: self.escapeDescription,
DisplayOnScreen: true,
},
{
@@ -121,28 +51,6 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
Description: self.c.Tr.StartSearch,
Tag: "navigation",
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp),
Handler: self.extendRangeUp,
Description: self.c.Tr.RangeSelectUp,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectUp),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{
Tag: "navigation",
Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown),
Handler: self.extendRangeDown,
Description: self.c.Tr.RangeSelectDown,
DescriptionFunc: self.diffSelectionDescriptionText(self.c.Tr.RangeSelectDown),
GetDisabledReason: self.diffSelectionDisabledReason,
},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{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},
}
}
@@ -160,19 +68,6 @@ func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts)
Handler: self.onClickInOtherViewOfMainViewPair,
FocusedView: self.otherContext.GetViewName(),
},
{
// Dragging after a click extends a range selection from the clicked line.
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.onDragInFocusedView,
FocusedView: self.context.GetViewName(),
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: self.onDragRelease,
},
}
}
@@ -181,691 +76,35 @@ func (self *MainViewController) Context() types.Context {
}
func (self *MainViewController) togglePanel() error {
if !self.otherContext.GetView().Visible {
return nil
if self.otherContext.GetView().Visible {
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
}
// Whether the pair holds a diff is decided by the side panel beneath, which
// NextInStack only finds while our context is still the focused main view, so
// read it before pushing the other pane.
isDiff := self.isDiffView()
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
if isDiff {
establishDiffSelection(self.c, self.otherContext, -1)
}
return nil
}
// escape dismisses the selection a step at a time before leaving the view: a range
// collapses to its cursor line, and hunk mode the user turned on goes back to
// line-by-line. Hunk mode that is merely the configured default is not something to
// escape from, so there escape leaves.
func (self *MainViewController) escape() error {
if self.selectingRange() || self.selectingHunkEnabledByUser() {
self.context.ResetDiffSelectMode()
return nil
}
self.c.Context().Pop()
return nil
}
func (self *MainViewController) escapeDescription() string {
if self.selectingRange() {
return self.c.Tr.DismissRangeSelect
}
if self.selectingHunkEnabledByUser() {
return self.c.Tr.SelectLineByLine
}
return self.c.Tr.ExitFocusedMainView
}
// selectingHunkEnabledByUser reports whether we are in hunk mode because the user
// asked for it, as opposed to it being the configured default.
func (self *MainViewController) selectingHunkEnabledByUser() bool {
return self.diffSelectState().Mode == types.DiffSelectModeHunk && self.diffSelectState().UserEnabledHunkMode
}
// isDiffView reports whether the focused main view currently shows a diff, and so
// shows a selection. See types.DiffMainViewContext.
func (self *MainViewController) isDiffView() bool {
return self.diffMainViewType() != types.DiffMainViewTypeNone
}
// diffMainViewType reports what the diff in the focused main view belongs to, taken
// from the side panel beneath it, or DiffMainViewTypeNone when this pane isn't on the
// stack or has no diff panel beneath it. The IsInStack guard is essential:
// NextInStack panics for a context that isn't in the stack, and GetKeybindings (which
// leads here) also runs for off-stack panes — at startup and while generating the
// cheatsheets, where the stack is empty.
func (self *MainViewController) diffMainViewType() types.DiffMainViewType {
if !self.c.Context().IsInStack(self.context) {
return types.DiffMainViewTypeNone
}
if diffContext, ok := self.c.Context().NextInStack(self.context).(types.DiffMainViewContext); ok {
return diffContext.GetDiffMainViewType()
}
return types.DiffMainViewTypeNone
}
// diffSelectState returns this pane's diff selection mode state.
func (self *MainViewController) diffSelectState() *types.DiffSelectState {
return self.context.DiffSelectState()
}
// diffSelectionDescription qualifies the description of a command that acts on the
// selection, so that it is listed only where it applies: the main view also shows
// content with nothing to select in it — a branch's commit log, the status dashboard —
// and a command with no description is left out of the keybindings menu.
//
// 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 *MainViewController) diffSelectionDescription(describe func() string) func() string {
return func() string {
if !self.isDiffView() {
return ""
}
return describe()
}
}
func (self *MainViewController) diffSelectionDescriptionText(description string) func() string {
return self.diffSelectionDescription(func() string { return description })
}
// diffSelectionDisabledReason disables the commands that act on the selection while
// there is none to act on: a diff view whose diff holds nothing selectable (a binary
// file, an empty commit) or which is showing a placeholder message.
func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledReason {
if !self.context.GetView().Highlight {
return &types.DisabledReason{Text: self.c.Tr.NothingToSelectInDiff}
}
return nil
}
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
self.selectClickedDiffLine(opts.Y)
sidePanelContext := self.c.Context().NextInStack(self.context)
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
}
return nil
}
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
// Carry the select mode over from the pane we're leaving, so that clicking into
// the other pane keeps hunk mode even the first time we enter it — its own mode
// would otherwise still be the default single line until it had been focused at
// least once. selectClickedDiffLine then keeps or collapses that mode depending on
// where the click landed.
*self.context.DiffSelectState() = *self.otherContext.DiffSelectState()
self.c.Context().Push(self.context, types.OnFocusOpts{})
self.selectClickedDiffLine(opts.Y)
return nil
}
// onDragInFocusedView extends a range selection as the mouse is dragged after a
// click, anchored at the line the click landed on rather than wherever the click left
// the selection — a click can select a whole hunk, whose far end would otherwise
// become the anchor. Dragging turns hunk mode off: you get a plain range from the
// clicked line to the line under the cursor, which gocui has already moved here.
func (self *MainViewController) onDragInFocusedView(opts gocui.ViewMouseBindingOpts) error {
view := self.context.GetView()
if !self.isDiffView() || !view.Highlight {
return nil
}
sel := self.diffSelectState()
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = false
sel.UserEnabledHunkMode = false
view.SetRangeSelectStart(self.context.DragAnchorViewLine())
// A drag that reaches the edge of the view keeps going: mouse capture means the
// pointer can be dragged past the edge, and there is more diff down there than
// fits on screen. opts.Y is where the pointer is in the content, which the
// autoscroller wants relative to the viewport.
self.draggingWithMouse = true
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *MainViewController) onDragRelease(gocui.ViewMouseBindingOpts) error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
// The drag moved the selection without going through showSelectionAtLine: gocui
// moves the cursor for it. Let the search catch up with where it ended.
self.context.GetView().SetNearestSearchPosition()
return nil
}
// GetOnFocusLost stops an autoscroll that is still running when the view loses focus
// mid-drag, e.g. because a popup appeared, and gives up the mouse capture with it —
// otherwise the pointer would keep driving a view that no longer has focus.
func (self *MainViewController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
// canDragAutoscroll reports whether the autoscroller should run: only while a drag is
// actually extending a range in a diff. Scrolling down also has to keep the lazily
// loaded content ahead of the scroll, or it would stop at the loaded edge.
func (self *MainViewController) canDragAutoscroll(direction int) bool {
if !self.draggingWithMouse || !self.isDiffView() {
return false
}
view := self.context.GetView()
if !view.Highlight || self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
if direction > 0 {
self.c.ReadLinesToFillView(view)
}
return true
}
// handleDragAutoscroll extends the selection to the line the pointer ends up over
// after the autoscroller has scrolled, leaving the range anchored where the drag
// started. It reports whether the autoscroll should carry on.
//
// The pointer is usually outside the view by now — that is what mouse capture is for —
// so the line it is over is clamped to the visible ones, leaving the selection's far
// end at the edge the scroll is moving towards.
func (self *MainViewController) handleDragAutoscroll(viewLine int) bool {
if !self.canDragAutoscroll(0) {
return false
}
view := self.context.GetView()
originY, viewportHeight := self.context.GetViewTrait().ViewPortYBounds()
target := lo.Clamp(viewLine, 0, max(0, view.ViewLinesHeight()-1))
view.SetCursorY(lo.Clamp(target-originY, 0, max(0, viewportHeight-1)))
return true
}
// selectClickedDiffLine sets the focused main view's selection from a click at the
// given view line. In hunk mode, clicking inside the selected block collapses it to
// that line; clicking a change line outside it keeps hunk mode and selects that block.
// A click on context, or any click outside hunk mode, selects just that line too.
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
if !self.isDiffView() {
return
}
view := self.context.GetView()
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
self.context.SetDragAnchorViewLine(viewLine)
if self.diffSelectState().Mode == types.DiffSelectModeHunk {
if start, end, ok := self.c.Helpers().DiffLine.SelectedHunkBounds(view); ok &&
viewLine >= start && viewLine <= end {
self.context.ResetDiffSelectMode()
showSelectionAtLine(view, viewLine, false)
return
}
if self.c.Helpers().DiffLine.IsChangeLine(view, viewLine) {
self.selectHunkAround(viewLine, false)
return
}
}
self.context.ResetDiffSelectMode()
showSelectionAtLine(view, viewLine, false)
}
// establishDiffSelection 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
// keyboard focus, which points at no particular line and so starts at the first
// change line on screen.
//
// Focusing never moves the view: you focus the diff you are reading in order to point
// at something in it, so the selection goes where you are looking rather than the
// view going where the selection would like to be. With no change line on screen at
// all — a long stretch of context — it lands on the middle visible line, the likeliest
// one to be the one being read.
//
// With hunk mode configured as the default the selection widens to the whole change
// block: keyboard focus lands on the first block on screen, and a click on a change
// line selects that line's block, ready to act on. A click on context still selects
// just that line — the click points at it precisely, so it stays editable.
func establishDiffSelection(c *ControllerCommon, mainContext *context.MainContext, clickedViewLine int) {
mainContext.ResetDiffSelectMode()
view := mainContext.GetView()
// The panel beneath renders a diff, but that diff may hold nothing to act on: a
// binary file, or an empty commit. Rendering it worked that out, so the pane is
// already showing no selection and there is nowhere to put one.
if !c.Helpers().DiffLine.ViewHasChangeLines(view) {
return
}
if clickedViewLine >= 0 {
// Remember where the click landed so that a drag that follows anchors its range
// there, even when this click selects a whole hunk.
mainContext.SetDragAnchorViewLine(clickedViewLine)
if hunkModeApplies(c, view, clickedViewLine) &&
c.Helpers().DiffLine.IsChangeLine(view, clickedViewLine) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
selectDiffHunk(c, mainContext, clickedViewLine, false)
return
}
showSelectionAtLine(view, clickedViewLine, false)
return
}
target, ok := changeToSelectOnScreen(c, view)
if !ok {
showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
return
}
if hunkModeApplies(c, view, target) {
mainContext.DiffSelectState().Mode = types.DiffSelectModeHunk
selectDiffHunk(c, mainContext, target, false)
return
}
showSelectionAtLine(view, target, false)
}
// 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
// block that reaches into the view from above — a change longer than the screen, where
// there is nothing else to offer. Line by line it is simply the first change line on
// screen. ok is false when the viewport shows no change at all.
func changeToSelectOnScreen(c *ControllerCommon, view *gocui.View) (int, bool) {
if c.UserConfig().Gui.UseHunkModeInStagingView {
return c.Helpers().DiffLine.FirstChangeBlockInView(view)
}
return c.Helpers().DiffLine.FirstChangeLineInView(view)
}
// hunkModeApplies reports whether an established selection should start out as the
// whole change block around the given change line. That's what the config asks for,
// except over a file shown as one solid block of changes, where it would select the
// whole file — see DiffLineHelper.IsSingleHunkForWholeFile.
func hunkModeApplies(c *ControllerCommon, view *gocui.View, changeViewLine int) bool {
return c.UserConfig().Gui.UseHunkModeInStagingView &&
!c.Helpers().DiffLine.IsSingleHunkForWholeFile(view, changeViewLine)
}
// showSelectionAtLine moves the focused main view's selection to the given view line,
// clamped to the content. scrollIntoView scrolls the line into view when it's
// off-screen, for navigating to it; a click leaves it false, the clicked line being on
// screen already.
func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) {
view.FocusPoint(0, lo.Clamp(lineIdx, 0, max(0, view.ViewLinesHeight()-1)), scrollIntoView)
// A search carries on from where the selection now is, so that stepping to the
// next match goes to the one after it rather than the one after the match the
// user last stepped to.
view.SetNearestSearchPosition()
}
func (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
selectDiffHunk(self.c, self.context, changeViewLine, scrollIntoView)
}
// selectDiffHunk selects the whole change block around the given change line, for
// hunk mode: the cursor goes to the block's first line and the range anchor to its
// last, so the native range highlight spans the block. With no block to be found —
// a diff with no changes in it — it falls back to a single-line selection.
//
// scrollIntoView brings the block's first line on screen, for the commands that mean
// to go there; a click leaves it false, so that the view doesn't move under the mouse
// when the block the click landed in starts above the viewport.
func selectDiffHunk(
c *ControllerCommon, mainContext *context.MainContext, changeViewLine int, scrollIntoView bool,
) {
view := mainContext.GetView()
start, end, ok := c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine)
if !ok {
mainContext.DiffSelectState().Mode = types.DiffSelectModeLine
view.CancelRangeSelect()
showSelectionAtLine(view, changeViewLine, scrollIntoView)
return
}
view.SetRangeSelectStart(end)
showSelectionAtLine(view, start, scrollIntoView)
}
// navigate moves the focused main view to the row find locates from the current
// anchor — the selected line when a selection is showing, otherwise the top visible
// 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.
// 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 {
anchor = v.SelectedLineIdx()
}
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
return
}
if !forward {
// Everything above the anchor has loaded, so a backward target that wasn't
// found doesn't exist.
return
}
// The diff loads lazily, so a target below the loaded portion isn't there to be
// found yet. Read the rest of it in and look again before concluding there is none.
manager := self.c.GetViewBufferManagerForView(v)
if manager == nil {
return
}
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
if target, ok := find(v, anchor, forward); ok {
self.placeNavigationTarget(target, alignTop)
}
return nil
})
self.c.Context().Push(self.context, types.OnFocusOpts{
ClickedWindowName: self.context.GetWindowName(),
ClickedViewLineIdx: opts.Y,
})
}
// findDiffRowFn locates a row of the rendered diff to navigate to, given the view,
// 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) 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
}
// Line mode leaves a single-line selection at the target; an active range extends
// to it, the anchor being untouched.
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
// the selected line otherwise.
func (self *MainViewController) moveCursor(delta int) {
v := self.context.GetView()
if delta > 0 {
self.c.ReadLinesToFillView(v)
}
before := v.SelectedLineIdx()
after := lo.Clamp(before+delta, 0, v.ViewLinesHeight()-1)
if delta == -1 {
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
} else if delta == 1 {
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), before, after)
}
showSelectionAtLine(v, after, true)
}
// collapseForLineMove drops hunk mode, and a non-sticky range, back to a single-line
// selection — what a plain (non-shift, non-hunk-step) move does before moving. A
// sticky range is kept, so the move extends it.
func (self *MainViewController) collapseForLineMove() {
sel := self.diffSelectState()
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()
}
}
// adjustSelection moves the selection by delta view lines, for the plain up/down and
// page keys. In hunk mode a single-line step jumps to the adjacent block, while a
// larger page step drops out of hunk mode first. A non-sticky range collapses back to
// a single line on a plain move. With no selection — non-diff content — it scrolls.
func (self *MainViewController) adjustSelection(delta int) {
if !self.context.GetView().Highlight {
self.handleLineChange(delta)
return
}
if self.diffSelectState().Mode == types.DiffSelectModeHunk && (delta == 1 || delta == -1) {
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0, false)
return
}
self.collapseForLineMove()
self.moveCursor(delta)
}
// selectAbsoluteLine moves the selection to a specific view line — the top or bottom
// of the diff — dropping hunk mode and a non-sticky range like a plain move does.
func (self *MainViewController) selectAbsoluteLine(target int) {
self.collapseForLineMove()
showSelectionAtLine(self.context.GetView(), target, true)
}
// selectingRange reports whether a range selection is currently active: we're in
// range mode and either it's sticky or the anchor and cursor differ, i.e. a
// non-sticky range that has actually been extended.
func (self *MainViewController) selectingRange() bool {
if self.diffSelectState().Mode != types.DiffSelectModeRange {
return false
}
start, end := self.context.GetView().SelectedLineRange()
return self.diffSelectState().RangeIsSticky || start != end
}
// toggleSelectHunk switches between selecting the change block around the cursor and
// a single line.
func (self *MainViewController) toggleSelectHunk() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if sel.Mode == types.DiffSelectModeHunk {
sel.Mode = types.DiffSelectModeLine
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeHunk
sel.UserEnabledHunkMode = true
self.selectHunkAround(v.SelectedLineIdx(), true)
}
return nil
}
// toggleRangeSelect starts or cancels a sticky range selection, which the plain
// up/down keys extend.
func (self *MainViewController) toggleRangeSelect() error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if self.selectingRange() {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
v.CancelRangeSelect()
} else {
sel.Mode = types.DiffSelectModeRange
sel.RangeIsSticky = true
v.SetRangeSelectStart(v.SelectedLineIdx())
}
return nil
}
// extendRange grows a non-sticky range selection by one line in response to
// shift+up/down, starting one at the cursor if there isn't one yet.
func (self *MainViewController) extendRange(forward bool) error {
v := self.context.GetView()
if !v.Highlight {
return nil
}
sel := self.diffSelectState()
if !self.selectingRange() {
sel.Mode = types.DiffSelectModeRange
v.SetRangeSelectStart(v.SelectedLineIdx())
}
sel.RangeIsSticky = false
if forward {
self.moveCursor(1)
} else {
self.moveCursor(-1)
}
return nil
}
func (self *MainViewController) extendRangeUp() error {
return self.extendRange(false)
}
func (self *MainViewController) extendRangeDown() error {
return self.extendRange(true)
}
func (self *MainViewController) handleLineChange(delta int) {
v := self.context.GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *MainViewController) handlePrevLine() error {
self.adjustSelection(-1)
return nil
}
func (self *MainViewController) handleNextLine() error {
self.adjustSelection(1)
return nil
}
func (self *MainViewController) handlePrevPage() error {
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleNextPage() error {
self.adjustSelection(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *MainViewController) handleGotoTop() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(0)
return nil
}
func (self *MainViewController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.context.GetView()
if !v.Highlight {
self.handleLineChange(v.ViewLinesHeight())
return nil
}
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
return nil
})
})
}
return nil
}
func (self *MainViewController) editLine() error {
view := self.context.GetView()
if !view.Highlight {
return nil
}
info, ok := self.c.Helpers().DiffLine.GetDiffLineInfo(view, view.SelectedLineIdx())
if !ok {
return nil
}
// A file-header row points at the file as a whole rather than at a line in it, so
// it opens the file without jumping anywhere — as pressing edit on a file in a side
// panel does.
if info.Type == types.DiffLineFileHeader {
return self.c.Helpers().Files.EditFiles([]string{info.Path})
}
// The diff may be of an older commit, whose line numbers aren't the file's current
// ones, so they have to be carried forward before we can point an editor at them.
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
}
func (self *MainViewController) openSearch() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
@@ -2,7 +2,6 @@ package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
@@ -62,24 +61,21 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
}
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
return self.focusMainView(self.c.Contexts().Normal)
}
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
return self.focusMainView(self.c.Contexts().NormalSecondary)
}
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
return self.focusMainView(self.c.Contexts().Normal, -1)
return self.focusMainView(self.c.Contexts().Normal)
}
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error {
mainViewContext.ClearSearchString()
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
if _, ok := self.context.(types.DiffMainViewContext); ok {
establishDiffSelection(self.c, mainViewContext, clickedLineIdx)
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
if context, ok := mainViewContext.(types.ISearchableContext); ok {
context.ClearSearchString()
}
self.c.Context().Push(mainViewContext, types.OnFocusOpts{})
return nil
}
@@ -0,0 +1,100 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type ViewSelectionControllerFactory struct {
c *ControllerCommon
}
func NewViewSelectionControllerFactory(c *ControllerCommon) *ViewSelectionControllerFactory {
return &ViewSelectionControllerFactory{
c: c,
}
}
func (self *ViewSelectionControllerFactory) Create(context types.Context) types.IController {
return &ViewSelectionController{
baseController: baseController{},
c: self.c,
context: context,
}
}
type ViewSelectionController struct {
baseController
c *ControllerCommon
context types.Context
}
func (self *ViewSelectionController) Context() types.Context {
return self.context
}
func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage},
{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},
}
}
func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
return []*gocui.ViewMouseBinding{}
}
func (self *ViewSelectionController) handleLineChange(delta int) {
v := self.Context().GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}
func (self *ViewSelectionController) handlePrevLine() error {
self.handleLineChange(-1)
return nil
}
func (self *ViewSelectionController) handleNextLine() error {
self.handleLineChange(1)
return nil
}
func (self *ViewSelectionController) handlePrevPage() error {
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleNextPage() error {
self.handleLineChange(self.context.GetViewTrait().PageDelta())
return nil
}
func (self *ViewSelectionController) handleGotoTop() error {
v := self.Context().GetView()
self.handleLineChange(-v.ViewLinesHeight())
return nil
}
func (self *ViewSelectionController) handleGotoBottom() error {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadToEnd(func() {
self.c.OnUIThread(func() error {
v := self.Context().GetView()
self.handleLineChange(v.ViewLinesHeight())
return nil
})
})
}
return nil
}
+1 -153
View File
@@ -117,7 +117,7 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
if opts.Secondary != nil {
gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary)
} else if opts.Pair.Secondary != nil {
gui.clearMainView(opts.Pair.Secondary)
opts.Pair.Secondary.GetView().Clear()
}
// Reset the scroll positions of all the other main views. We do this after
@@ -137,158 +137,6 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
gui.splitMainPanel(opts.Secondary != nil)
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
// content when the render that just finished left the diff with fewer lines than the
// selection was on — a diff renderer that renders the same diff more compactly, a
// smaller context size. That selection lives in the view rather than in a model, so
// nothing else re-derives it, and past the end of the content it isn't drawn at all,
// which reads as having no selection until an arrow key brings it back.
//
// Called at end of input, when the content is final: doing it while the render is
// still loading would drag the selection to a line that only looks like the last one.
// Only these two views need it; every other view's selection is derived from a model
// as it renders, and so is clamped along with it.
func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
if gui.mainContextForView(view) == nil {
return
}
if !view.Highlight {
return
}
if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine {
view.FocusPoint(0, max(0, lastLine), true)
}
}
// clearMainView empties a pane that is being given nothing to show, selection and all.
func (gui *Gui) clearMainView(mainContext types.Context) {
mainContext.GetView().Clear()
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
// updateDiffSelectionVisibility works out whether a main pane holds anything for a
// selection to sit on, from what it is now showing: only beneath a panel whose main
// view is a diff, and only while that diff holds something to select — never over a
// message like "No changed files", and never over a diff with nothing in it, such as a
// binary file's or an empty commit's. Whether the selection is then drawn, and drawn as
// the active one, follows from the context stack.
//
// It is asked wherever the pane's content changes: as a string is rendered, at the
// paint that reveals a command's output, with every further batch of that output, and
// once it has been read to the end. contentIsComplete tells those apart, since a render
// still being read can leave the question open (see diffPaneHasSomethingToSelect). The
// pane never answers from the render before it, and a render that leaves the question
// open is read on until it doesn't, so the answer is always about what is there.
func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplete bool) {
mainContext := gui.mainContextForView(view)
if mainContext == nil {
return
}
gui.dropAnAnswerAboutAnotherRender(mainContext, view)
if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect(
mainContext, view, contentIsComplete,
); known {
mainContext.SetHasSelectableContent(hasSomethingToSelect)
gui.State.ContextMgr.UpdateSelectionHighlights()
} else {
gui.readOnUntilTheDiffPaneCanTell(view)
}
}
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
// of an earlier render, so that this one starts from no answer rather than inheriting
// one. An answer about other content says nothing about this content: carried over, it
// shows a selection over a diff that may have nothing to select, or hides one over a
// diff that has.
//
// A re-render of the same content keeps its answer, and with it the selection drawn
// over it, since that answer is still about what the pane is showing.
func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() {
mainContext.SetSelectableContentRenderKey(key)
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
}
// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of
// it, while the pane still can't say whether there is anything in it to select.
//
// A render is asked for as many lines as the scrollbar needs (see
// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the
// diffstat of a commit touching thousands of files, or a commit message thousands of
// lines long. Without this the pane would be left with no answer until the user
// scrolled far enough to ask for the rest themselves, which is no way to find out
// whether a diff can be acted on. Another render's worth is asked for each time, so the
// reading stops soon after the first change line, and only runs to the end of a diff
// that has none.
func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
step := gui.linesToReadFromCmdTask(view).Total
if step < 0 {
// A view that is being searched is already being read to the end.
return
}
manager.ReadLinesAndWait(view.LinesHeight() + step)
}
// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a
// selection to sit on, from what it is showing so far. known is false while a render
// still being read leaves the question open.
func (gui *Gui) diffPaneHasSomethingToSelect(
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
) (bool, bool) {
if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff {
// Under a panel that shows no diff there is nothing to select whatever the pane
// ends up holding, so this needs no content to answer. Answering it now matters,
// because a render may never reach an end. The rest of a long commit log is read
// only as far as the user scrolls, and until then the pane would go on showing
// the selection it was left with under the panel before.
return false, true
}
if !contentIsComplete && mainContext.HasSelectableContent() {
// This render has already found something to select, and its content only grows
// from here, so there is nothing to ask again — nor to read the diff for. An
// answer the render before it gave has been dropped by now (see
// dropAnAnswerAboutAnotherRender), so this really is about the content in hand.
return true, true
}
hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view)
// One change line among those read settles it. Finding none in a render that is
// still going may only mean the changes are in the part still to come. A commit's
// diff opens with a diffstat, and for a commit touching hundreds of files that runs
// well past the screenful the first paint reveals, so that answer waits.
return hasChangeLines, contentIsComplete || hasChangeLines
}
// mainContextForView returns the context of the main pane the given view is, or nil for
// any other view.
func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
switch view {
case gui.Views.Main:
return gui.State.Contexts.Normal
case gui.Views.Secondary:
return gui.State.Contexts.NormalSecondary
}
return nil
}
func (gui *Gui) splitMainPanel(splitMainPanel bool) {
gui.State.SplitMainPanel = splitMainPanel
}
-10
View File
@@ -36,16 +36,6 @@ type renderSpec struct {
// user has configured. The renderer lays its rendering out to the width of the
// view, which only the layout settles, so the task is created after it.
func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
// Ask whatever renders the diff to state, in an OSC 1717 record per line,
// which line of which file it is rendering. This lets us act on the line the
// user is pointing at even when the rendering no longer looks like a diff.
// The variable names the protocol versions we understand, and a renderer
// that doesn't understand it ignores it, so we can set it always. It has to
// be set before the plain path below, since on that path git renders the
// diff itself, and git speaks the protocol too, for its word-diff formats,
// whose markup we could not otherwise resolve.
cmd.Env = append(cmd.Env, "OSC1717=V1")
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
// If we're not using a custom diff renderer, then we don't need to use a pty
return gui.newCmdTask(view, cmd, prefix)
+5 -27
View File
@@ -91,7 +91,6 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -110,7 +109,6 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -129,7 +127,6 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
gui.updateDiffSelectionVisibility(view, true)
gui.reApplySearch(view)
})
}
@@ -157,20 +154,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
func() {
// As the task reads more lines, the only thing that changes is the
// view's content (and its scrollbar); the window layout doesn't. So a
// content-only render is enough — it skips the layout pass and redraws
// only the cells that differ — and it's much cheaper than a full
// layout-and-redraw on every read, which matters a lot when reading a
// long diff, where reads happen repeatedly as the user scrolls.
//
// What this draws is more of the content than the pane held a moment
// ago, so it is also where what is drawn over that content is worked
// out again. The screenful the first paint reveals may not be enough
// to say whether there is anything to select, and for a diff that
// opens with a long diffstat it isn't.
gui.c.OnUIThreadContentOnly(func() error {
gui.updateDiffSelectionVisibility(view, false)
return nil
})
// content-only render is enough, and it's much cheaper than a full
// layout-and-redraw on every read - which matters a lot when reading
// a long diff, where reads happen repeatedly as the user scrolls.
gui.renderContentOnly()
},
func() {
// The content is fully loaded now, so let the scrollbar track it
@@ -187,22 +174,13 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.SetOrigin(0, newOriginY)
}
gui.updateDiffSelectionVisibility(view, true)
gui.clampDiffSelectionToContent(view)
gui.reApplySearch(view)
},
func() {
view.SetOrigin(0, 0)
},
view.BeginOffscreenRender,
func() {
view.SwapInOffscreenRender()
// The content the pane is being given is on display from here on, so
// what is drawn over it is settled against that content rather than
// against the render before it.
gui.updateDiffSelectionVisibility(view, false)
},
view.SwapInOffscreenRender,
func() gocui.Task {
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not
+7 -45
View File
@@ -78,7 +78,6 @@ type IBaseContext interface {
// true if the context holds something for a selection to sit on. Contexts that
// don't show a selection at all say false, and so do lists with nothing in them.
HasSelectableContent() bool
SetHasSelectableContent(bool)
// the total height of the content that the view is currently showing
TotalContentHeight() int
@@ -103,6 +102,9 @@ type IBaseContext interface {
// that the generic ListController can be specialized by view-specific controllers.
// We'll need to think of a better way to do this.
AddOnDoubleClickFn(func() error)
// Likewise for the focused main view: we need this to communicate between a
// side panel controller and the focused main view controller.
AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error)
// Adding on to the above, this is so that a list-specific handler can register
// a hook for doing additional click handling
AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error)
@@ -178,48 +180,6 @@ type DiffableContext interface {
RefForAdjustingLineNumberInDiff() string
}
// DiffMainViewContext is implemented by the side panel contexts whose focused
// main view shows a unified diff — files, local commits, sub-commits, reflog,
// stash, and commit files — as opposed to a commit log or other non-diff content
// (branches, tags, status, …). It is distinct from DiffableContext, which is
// about producing a diff between two refs for the diff menu. The focused main
// view shows a selection only for a context that implements this: a selection is
// only meaningful where there are diff lines to act on (edit one, copy some, jump
// by hunk or file). The returned type additionally classifies what acting on that
// selection means.
type DiffMainViewContext interface {
Context
GetDiffMainViewType() DiffMainViewType
}
// DiffMainViewType classifies what the focused main view's diff belongs to, which
// decides what acting on a selection in it means.
type DiffMainViewType int
const (
// DiffMainViewTypeNone: the main view holds no diff, so there is nothing to
// select. A side panel that doesn't implement DiffMainViewContext counts as
// this; no panel returns it itself.
DiffMainViewTypeNone DiffMainViewType = iota
// DiffMainViewTypeStaging: the diff is the working tree's, so the selection can
// be staged or unstaged (the files panel).
DiffMainViewTypeStaging
// DiffMainViewTypePatchBuilding: the diff belongs to a commit, so the selection
// can be taken into a custom patch (the commit files / commits / sub-commits /
// reflog / stash panels).
DiffMainViewTypePatchBuilding
)
// DiffPaneContext is one of the two panes the main section can show, as the thing
// that holds a diff with a selection in it. The panels that act on such a selection
// are handed the pane it was made in, and speak to it through this.
type DiffPaneContext interface {
Context
DiffSelectState() *DiffSelectState
}
type IListContext interface {
Context
@@ -315,6 +275,10 @@ type HasKeybindings interface {
// HandleFocus has already been called (so the main view is up to date). Should return nil if it
// decides not to do anything with the click.
GetOnClick() func(opts gocui.ViewMouseBindingOpts) error
// Implement this in a side-panel controller to get called when there's a click in the main view
// that belongs to your panel while the main view is already focused.
GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error
}
type IController interface {
@@ -375,8 +339,6 @@ type IContextMgr interface {
CurrentSide() Context
CurrentPopup() []Context
NextInStack(context Context) Context
IsInStack(context Context) bool
UpdateSelectionHighlights()
IsCurrent(c Context) bool
IsCurrentOrParent(c Context) bool
ForEach(func(Context))
-42
View File
@@ -1,42 +0,0 @@
package types
// DiffLineType classifies a row of a rendered diff.
type DiffLineType int
const (
DiffLineFileHeader DiffLineType = iota
DiffLineHunkHeader
DiffLineContext
DiffLineAdded
DiffLineDeleted
// DiffLineOther is anything that isn't one of the above, e.g. the
// "\ No newline at end of file" marker.
DiffLineOther
)
// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch
// it was rendered from: which file the row belongs to, what kind of row it is,
// and where the line sits in the old and new versions of that file. This lets us
// act on the line the user is pointing at in a diff view: stage it, open it in an
// editor, keep the cursor on it across a re-render. The rendered text alone tells
// us none of that.
type DiffLineInfo struct {
// Path is the absolute path of the file the line belongs to.
Path string
Type DiffLineType
// NewLine is the line's position in the new version of the file. Set for all
// content lines (for a deletion it is the position the deletion sits at) and
// for hunk headers (the first line of the hunk they head).
NewLine int
// OldLine is the line's position in the old version of the file. Set only
// for deletions, which are the only rows that need it: two consecutive
// deletions share a new-file position and differ only here.
OldLine int
}
// IsChange reports whether the row is an added or deleted line, as opposed to a
// context line or a header. It mirrors patch.PatchLine.IsChange: those are the
// rows a patch is built from, and the rows navigation moves between.
func (self DiffLineInfo) IsChange() bool {
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
}
-28
View File
@@ -1,28 +0,0 @@
package types
// DiffSelectMode is how the focused main view's diff selection extends from the
// cursor: a single line, a range from a fixed anchor, or the change block (hunk)
// around the cursor.
type DiffSelectMode int
const (
DiffSelectModeLine DiffSelectMode = iota
DiffSelectModeRange
DiffSelectModeHunk
)
// DiffSelectState holds the *mode* of the focused main view's diff selection. The
// selected line and the range anchor themselves live in the gocui view (its cursor
// and range-select start), so only the mode lives here. It's on the context rather
// than on the controller because the controller that drives the selection, the
// controller that establishes it on focus, and the pane-toggle that seeds it on the
// other pane all reach the pane through its context.
type DiffSelectState struct {
Mode DiffSelectMode
// When a range is sticky, moving the cursor without holding shift extends the
// range; otherwise it collapses the range back to a single line.
RangeIsSticky bool
// Whether hunk mode was turned on by the user rather than being the configured
// default. This decides whether escape leaves hunk mode or leaves the view.
UserEnabledHunkMode bool
}
+9 -1
View File
@@ -133,6 +133,14 @@ func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// renderContentOnly triggers a re-render that skips the layout pass and only
// redraws the views whose content changed (relying on tcell's cell-level dirty
// tracking to emit just the cells that actually differ). Use it when only a
// view's content changed, not the window layout.
func (gui *Gui) renderContentOnly() {
gui.c.OnUIThreadContentOnly(func() error { return nil })
}
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
@@ -146,7 +154,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
// The render may have given the context its first item, or taken its last one
// away, which decides whether its view draws a selection at all.
gui.State.ContextMgr.UpdateSelectionHighlights()
gui.State.ContextMgr.updateSelectionHighlights()
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
-9
View File
@@ -168,15 +168,6 @@ func (gui *Gui) createAllViews() error {
gui.Views.Snake.FgColor = gocui.ColorGreen
// The main views show diffs, whose own colors say what each line is: which side of
// the diff it's on, and often its syntax highlighting too. A selection painted
// across the whole line takes those colors over, which for a whole selected hunk
// leaves one unreadable block; so mark the selection with a narrow bar at the left
// edge instead, and leave the rest of the line to the diff. Two columns, enough to
// read as a marker rather than as an artefact.
gui.Views.Main.SelectedLineColorWidth = 2
gui.Views.Secondary.SelectedLineColorWidth = 2
return nil
}
-6
View File
@@ -310,7 +310,6 @@ type TranslationSet struct {
DiscardSelectionTooltip string
ToggleSelectHunk string
SelectHunk string
NothingToSelectInDiff string
SelectLineByLine string
ToggleSelectHunkTooltip string
HunkStagingHint string
@@ -411,8 +410,6 @@ type TranslationSet struct {
AskQuestion string
PrevHunk string
NextHunk string
PrevFileInDiff string
NextFileInDiff string
PrevConflict string
NextConflict string
SelectPrevHunk string
@@ -1496,7 +1493,6 @@ func EnglishTranslationSet() *TranslationSet {
ToggleRangeSelect: "Toggle range select",
DismissRangeSelect: "Dismiss range select",
ToggleSelectHunk: "Toggle hunk selection",
NothingToSelectInDiff: "There is nothing to select here",
SelectHunk: "Select hunks",
SelectLineByLine: "Select line-by-line",
ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.",
@@ -1602,8 +1598,6 @@ 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",
-18
View File
@@ -141,24 +141,6 @@ 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)
@@ -1,50 +0,0 @@
package diff
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DiffRendererMetadata = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A diff renderer is told that we understand the OSC 1717 metadata protocol, and the records it emits don't show up in the rendered diff",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// A fake conforming renderer: it announces the protocol with a
// version-only record, reports the protocol versions it was offered, and
// then passes the diff through with a per-line record in front of every
// line.
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: `printf '\033]1717;1\007'; ` +
`printf 'OFFERED:%s\n' "$OSC1717"; ` +
`while IFS= read -r line; do printf '\033]1717;1;c;1;;file1\007%s\n' "$line"; done`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("one").IsSelected(),
)
t.Views().Main().
// The renderer was offered the protocol version we understand.
Content(Contains("OFFERED:V1")).
// Its records are escape sequences, so none of them reaches the
// screen; the diff reads exactly as the renderer wrote it.
ContainsLines(
Equals("diff --git a/file1 b/file1"),
Contains("new file mode"),
Contains("index "),
Equals("--- /dev/null"),
Equals("+++ b/file1"),
Equals("@@ -0,0 +1,2 @@"),
Equals("+one"),
Equals("+two"),
)
},
})
@@ -1,40 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ClickSelectsDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Clicking a line of the main view's diff focuses the view and selects that line",
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\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused()
// The click both focuses the view and points at a line, so that line is selected
// rather than the first change.
t.Views().Main().
Click(0, 4).
IsFocused().
SelectionIsActive().
SelectedLines(
Contains("@@ -1,5 +1,5 @@"),
).
// A click in the already-focused view moves the selection to the clicked line.
Click(0, 7).
SelectedLines(
Contains("-three"),
)
},
})
@@ -1,43 +0,0 @@
package main_view
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragRangeWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep scrolling while dragging a range selection at the edge of the focused main view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
fileContent := "base\n"
shell.CreateFileAndAdd("file1", fileContent)
shell.Commit("one")
for i := 1; i <= 40; i++ {
fileContent += fmt.Sprintf("line %d\n", i)
}
shell.UpdateFile("file1", fileContent)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The diff is longer than the view, so holding the pointer at the bottom edge has
// to keep scrolling and extending the selection rather than stopping there.
t.Views().Main().
IsFocused().
ClickAndHold(1, 6).
MouseMoveToBottom(1).
OriginYAtLeast(3).
SelectedLineIdxAtLeast(9).
MouseRelease()
},
})
@@ -1,55 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragSelectsDiffLineRange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Dragging in the main view's diff selects the range from the line the drag started on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Hunk mode is on, so the mouse-down alone selects the whole block; the drag
// anchors the range where the mouse went down instead, one line at a time.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
ClickAndHold(0, 8).
MouseMove(0, 9).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
).
MouseMove(0, 10).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
MouseRelease().
SelectedLines(
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
)
},
})
@@ -1,45 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EditSelectedDiffLine = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Open the selected line of the main view's diff in the editor, at that line of the file",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command"
config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command"
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// The addition is the third line of the file as it now stands.
t.Views().Main().
IsFocused().
NavigateToLine(Contains("+THREE")).
Press(keys.Universal.Edit)
// The editor is pointed at the file by absolute path.
t.FileSystem().FileContent("edit-command", Contains("/repo/file1:3\n"))
// A file header points at the file rather than at a line in it, so it opens the
// file with no line to jump to.
t.Views().Main().
NavigateToLine(Contains("diff --git a/file1 b/file1")).
Press(keys.Universal.Edit)
t.FileSystem().FileContent("edit-command", Contains("/repo/file1\n"))
},
})
@@ -1,59 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var EscapeDismissesSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Escape gives up a range selection, then hunk mode, before leaving the focused main view",
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\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// A sticky range: escape collapses it to the cursor line rather than leaving.
t.Views().Main().
IsFocused().
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-two"),
Contains("-three"),
).
Press(keys.Universal.Return).
IsFocused().
SelectedLines(
Contains("-three"),
).
// Hunk mode the user asked for: escape goes back to line-by-line.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-two"),
Contains("-three"),
Contains("+TWO"),
Contains("+THREE"),
).
Press(keys.Universal.Return).
IsFocused().
SelectedLines(
Contains("-two"),
).
// With nothing left to give up, escape leaves.
Press(keys.Universal.Return)
t.Views().Files().
IsFocused()
},
})
@@ -1,88 +0,0 @@
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()
},
})
@@ -1,47 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var HideSelectionWhenChangesVanish = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The main view's selection disappears along with the changes it was on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
Press(keys.Universal.Return)
// Discarding the change leaves the main view with a placeholder to show, so the
// selection that was on the change goes with it rather than lingering over the
// message.
t.Views().Files().
IsFocused().
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Menu().
Title(Equals("Discard changes")).
Select(Contains("Discard all changes")).
Confirm()
}).
IsEmpty()
t.Views().Main().
Content(Contains("No changed files")).
SelectionIsHidden()
},
})
@@ -1,58 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var KeepSelectionVisibleWhenDiffShrinks = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The selection stays on the content when a re-render leaves the diff with fewer lines than the selection was on",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
{Command: "cat"},
// The same diff in fewer lines, as a renderer that collapses or elides
// parts of it would give us: the addition at the end goes, and the hunk
// header says so, since a diff that contradicts its own header can't be
// read as one. (It has to read all of its input: one that exits early
// leaves the render looking like it is still loading, which holds off the
// clamping this test is about.)
{Name: "shrinking", Command: `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nFIVE\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.GotoBottom).
SelectedLines(
Contains("+FIVE"),
).
Press(keys.Universal.CycleDiffRenderers).
Tap(func() {
t.ExpectToast(Equals("Diff renderer: shrinking (2 of 2)"))
})
// The selection has nowhere to be but the last line there is. Asserting on the
// index first waits for that to happen: the re-render and the clamp that
// follows it are a frame apart, and reading the selected line's text in
// between would be reading past the content.
t.Views().Main().
SelectionIsActive().
SelectedLineIdx(10).
SelectedLines(
Contains("-five"),
)
},
})
@@ -1,88 +0,0 @@
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"),
)
},
})
@@ -1,36 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionOverABinaryDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A diff with nothing selectable in it shows no selection, and a refresh doesn't bring one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("binary", "\x00one\x00two\x00")
shell.Commit("one")
shell.UpdateFile("binary", "\x00one\x00TWO\x00")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// git says only that the file differs, so there is nothing to select — and a
// refresh, which renders the same diff again, doesn't make one appear.
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
Tap(func() {
t.GlobalPress(keys.Universal.Refresh)
}).
SelectionIsHidden()
},
})
@@ -1,48 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionOverACommitLog = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a branch's commit log shows no selection, even for a log too long to be read in one go",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the log below is longer than the initial read of it.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(60)
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("add file1")
shell.UpdateFile("file1", "one\ntwo modified\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Leave a selection behind in the main view, on the file's diff.
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive().
PressEscape()
t.Views().Branches().
Focus()
t.Views().Main().
Content(Contains("commit-60"))
// A commit log holds nothing to point at, so focusing it shows no selection —
// not even the one the pane was left with under the files panel.
t.Views().Branches().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden()
},
})
@@ -1,31 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var NoSelectionWhenNoChanges = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view when there are no changes shows no selection, and navigating doesn't conjure one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
IsEmpty().
Press(keys.Universal.FocusMainView)
// There's nothing to act on, so the placeholder is shown with no selection — and
// a navigation key just scrolls rather than conjuring one.
t.Views().Main().
IsFocused().
Content(Contains("No changed files")).
SelectionIsHidden().
Press(keys.Universal.GotoTop).
SelectionIsHidden()
},
})
@@ -1,59 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RangeSelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Select a range of diff lines in the focused main view, both sticky and with shift",
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\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// A sticky range is extended by the plain arrow keys, and pressing the key again
// collapses it back to the cursor line.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.ToggleRangeSelect).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-three"),
Contains("-four"),
).
Press(keys.Universal.ToggleRangeSelect).
SelectedLines(
Contains("-four"),
).
// A non-sticky range only grows while shift is held, and a plain move
// collapses it again.
Press(keys.Universal.RangeSelectDown).
SelectedLines(
Contains("-four"),
Contains("+THREE"),
).
Press(keys.Universal.RangeSelectUp).
SelectedLines(
Contains("-four"),
).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+THREE"),
)
},
})
@@ -1,51 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SearchCollapsesTheSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Searching the focused main view leaves a single line selected at the match, whatever was selected before",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// The match is in another block entirely, so the hunk selection the cursor
// has just left goes with it.
FilterOrSearch("NINE").
SelectedLines(
Contains("+NINE"),
).
// And the same for a hunk selected while a search is on: the next match is
// not part of it either.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
Press(keys.Universal.NextMatch).
SelectedLines(
Contains("+NINE"),
)
},
})
@@ -1,44 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SearchFollowsTheSelection = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Stepping to the next match in the focused main view carries on from the selection",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "")
shell.Commit("one")
shell.UpdateFile("file1", "NEEDLE a\ntwo\nNEEDLE b\nfour\nNEEDLE c\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
FilterOrSearch("NEEDLE").
SelectedLines(Contains("+NEEDLE a")).
Tap(func() {
t.Views().Search().Content(Contains("matches for 'NEEDLE' (1 of 3)"))
}).
// Move the selection past the second match by hand.
SelectNextItem().
SelectNextItem().
SelectNextItem().
SelectedLines(Contains("+four")).
Tap(func() {
t.Views().Search().Content(Contains("matches for 'NEEDLE' (2 of 3)"))
}).
// So the next match is the one after where the selection is, not the one
// after the match it was last on.
Press(keys.Universal.NextMatch).
SelectedLines(Contains("+NEEDLE c"))
},
})
@@ -1,64 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectBelowALongCommitMessage = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a commit whose diff begins below a very long commit message still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the message below is longer than a render is asked to
// read, and the diff under it is only reached by reading on.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("first commit")
body := make([]string, 1000)
for i := range body {
body[i] = fmt.Sprintf("message line %d", i+1)
}
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("a commit with a great deal to say\n\n" + strings.Join(body, "\n"))
shell.EmptyCommit("nothing to see here")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit with nothing to select leaves the pane showing no selection.
t.Views().Commits().
Focus().
Lines(
Contains("nothing to see here").IsSelected(),
Contains("a commit with a great deal to say"),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
PressEscape()
// The commit below it has a change, a thousand lines further down than a render
// reads by itself. The pane reads on until it knows, rather than taking the
// answer from the commit before it or waiting for the user to scroll.
t.Views().Commits().
IsFocused().
SelectNextItem().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// The change is in the pane, a thousand lines below where the render stopped
// reading of its own accord.
Content(Contains("-two")).
SelectionIsActive()
},
})
@@ -1,61 +0,0 @@
package main_view
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectBelowALongDiffstat = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a commit whose diff begins below a long diffstat still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the diffstat below fills more than the screenful the
// first paint reveals, and the diff itself is longer than the initial read of it.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
for i := range 40 {
shell.CreateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\ntwo\nthree\n")
}
shell.Commit("first commit")
for i := range 40 {
shell.UpdateFileAndAdd(fmt.Sprintf("file%02d", i+1), "one\nTWO\nthree\n")
}
shell.Commit("touch every file")
shell.EmptyCommit("nothing to see here")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A commit with nothing to select leaves the pane showing no selection.
t.Views().Commits().
Focus().
Lines(
Contains("nothing to see here").IsSelected(),
Contains("touch every file"),
Contains("first commit"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
PressEscape()
// The commit below it has plenty to select, even though none of it is among
// the diffstat the first paint shows.
t.Views().Commits().
IsFocused().
SelectNextItem()
t.Views().Main().Content(Contains("40 files changed"))
t.Views().Commits().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive()
},
})
@@ -1,54 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view selects the first visible change line, and the arrow keys move the selection",
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\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
// The selection starts on the first change line rather than at the top of the
// diff, so that it lands on something to act on without the view jumping.
t.Views().Main().
IsFocused().
SelectionIsActive().
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.NextItem).
SelectedLines(
Contains("+THREE"),
).
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.GotoTop).
SelectedLines(
Contains("diff --git a/file1 b/file1"),
).
Press(keys.Universal.Return)
t.Views().Files().
IsFocused()
},
})
@@ -1,37 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkBelowLastChange = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggling hunk selection while below the last change selects the last change block",
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\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\nTWO\nthree\nfour\nfive\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Below the last change there is no block ahead to select, so hunk mode takes
// the one behind rather than doing nothing.
t.Views().Main().
IsFocused().
NavigateToLine(Contains(" five")).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-two"),
Contains("+TWO"),
)
},
})
@@ -1,59 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkInDiff = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Toggle hunk selection in the focused main view, and step from hunk to hunk",
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.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nFOUR\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// Hunk mode widens the selection to the whole change block around the cursor —
// which is lazygit's notion of a hunk, so the two changed lines and their
// replacements are one block, and the isolated change further down is another.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
// In hunk mode the arrow keys step from block to block rather than by line.
Press(keys.Universal.NextItem).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
Press(keys.Universal.PrevItem).
SelectedLines(
Contains("-three"),
Contains("-four"),
Contains("+THREE"),
Contains("+FOUR"),
).
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-three"),
)
},
})
@@ -1,67 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "When hunk mode is the default, focusing the main view selects the first whole change block",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.Commit("one")
shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
// No key press needed: the whole block is selected just by focusing.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
Contains("+THREE"),
).
// Hunk mode being the configured default, it isn't something escape gives up:
// escape leaves the view.
Press(keys.Universal.Return)
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
// A click on a change line keeps hunk mode and selects that line's block.
Click(0, 14).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
// A click inside the selected block collapses hunk mode to that line.
Click(0, 15).
SelectedLines(
Contains("+NINE"),
).
// Switch back to hunk mode so the context click below proves that it gives
// hunk mode up, rather than merely keeping line mode.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("-nine"),
Contains("+NINE"),
).
// A click on a context line points at it precisely, so it selects that line.
Click(0, 12).
SelectedLines(
Contains(" seven"),
)
},
})
@@ -1,36 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectInADiffReadInPart = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view over a diff longer than the part of it that has been read still shows a selection",
ExtraCmdArgs: []string{},
Skip: false,
// A short terminal, so that the file below is longer than the initial read of its diff.
Width: 100,
Height: 20,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
lines := make([]string, 600)
for i := range lines {
lines[i] = fmt.Sprintf("line%03d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one big commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsActive()
},
})
@@ -1,43 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectLineWhenWholeFileIsOneHunk = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hunk mode falls back to a single line for a file that is one solid block of changes, rather than selecting all of it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.CreateFileAndAdd("added", "one\ntwo\nthree\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Contains("added").IsSelected(),
).
Press(keys.Universal.FocusMainView)
// Every line of the file is an addition, so widening to the change block would
// select the file entire; hunk mode gives way to a single line.
t.Views().Main().
IsFocused().
SelectedLines(
Contains("+one"),
).
// Toggling hunk mode on explicitly still selects the whole block: the fallback
// is about what the default does, not about forbidding the selection.
Press(keys.Main.ToggleSelectHunk).
SelectedLines(
Contains("+one"),
Contains("+two"),
Contains("+three"),
)
},
})
@@ -1,77 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectVisibleChangeOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view selects a change that is already on screen, leaving the diff where it is",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
// Enough context around each change to scroll into a stretch of the diff that
// holds none.
cfg.GetUserConfig().Git.DiffContextSize = 20
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 60)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
lines[4] = strings.ToUpper(lines[4])
lines[54] = strings.ToUpper(lines[54])
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain)
t.Views().Main().
OriginY(4).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
// The first change is on screen, so it is the one to point at — and the view
// hasn't moved to point at it.
SelectedLines(
Contains("-line05"),
).
OriginY(4).
PressEscape()
t.Views().Files().
IsFocused().
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain).
Press(keys.Universal.ScrollDownMain)
t.Views().Main().
OriginY(12).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
// Now the whole screen is context: the changes are above and below it. The
// selection goes to the middle of what is on screen rather than to a change
// the user would have to be scrolled to.
SelectedLines(
Contains(" line19"),
).
SelectedLineIdx(24).
OriginY(12)
},
})
@@ -1,93 +0,0 @@
package main_view
import (
"fmt"
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectVisibleHunkOnFocusingMainView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Focusing the main view in hunk mode picks a block that begins on screen, leaving the diff where it is",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true
// One line per scroll, so that the test can put the top of the view exactly
// where it wants it, and enough context to scroll about within one hunk.
cfg.GetUserConfig().Gui.ScrollHeight = 1
cfg.GetUserConfig().Git.DiffContextSize = 20
},
SetupRepo: func(shell *Shell) {
lines := make([]string, 80)
for i := range lines {
lines[i] = fmt.Sprintf("line%02d", i+1)
}
shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n")
shell.Commit("one")
for _, i := range []int{10, 20, 70} {
lines[i-1] = strings.ToUpper(lines[i-1])
}
shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
scrollDown := func(lines int) {
for range lines {
t.Views().Files().Press(keys.Universal.ScrollDownMain)
}
}
t.Views().Files().IsFocused()
// The top of the view is the second line of the first change block, so that
// block is only half on screen; the second one begins below it, in full.
scrollDown(15)
t.Views().Main().
OriginY(15).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
OriginY(15).
PressEscape()
// Now nothing begins on screen: the second block starts just above the top and
// the third change is far below. Only the half-visible block is on screen, so
// it is selected, with its first line off screen, since the view stays put.
scrollDown(11)
t.Views().Main().
OriginY(26).
Tap(func() {
t.Views().Files().Press(keys.Universal.FocusMainView)
}).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
SelectedLineIdx(25).
OriginY(26).
PressEscape()
// And a click inside a block that begins above the viewport selects the whole
// block without pulling the view up to its start either.
t.Views().Main().
Click(0, 0).
IsFocused().
SelectedLines(
Contains("-line20"),
Contains("+LINE20"),
).
OriginY(26)
},
})
@@ -1,58 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectionCommandsOnlyWhereTheyApply = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The keybindings menu offers the commands that act on a diff selection only in a main view that has one",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// A branch's commit log has nothing to select, so the commands that act on a
// selection have no business being listed there.
t.Views().Branches().
Focus().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Tap(func() {
t.Views().Menu().
Content(DoesNotContain("Select hunks")).
Content(DoesNotContain("Toggle range select")).
Content(DoesNotContain("Go to next hunk")).
Content(DoesNotContain("Go to next file"))
}).
Cancel()
// A diff view does list them, and with nothing changed to select they're
// offered but disabled.
t.Views().Files().
Focus().
IsEmpty().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectionIsHidden().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
Select(Contains("Select hunks")).
Confirm()
t.ExpectToast(Contains("There is nothing to select here"))
},
})
@@ -1,47 +0,0 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SelectionOverTheCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "The pane showing a custom patch keeps its selection across a refresh, its content being a diff like any other",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
NavigateToLine(Contains("two")).
PressEnter()
t.Views().CommitFiles().
IsFocused().
PressPrimaryAction().
Press(keys.Universal.FocusMainView)
// The patch built from the commit is shown in the other pane, and it is a diff,
// so it has a selection of its own — one that a refresh doesn't take away.
t.Views().Main().
IsFocused().
Press(keys.Universal.TogglePanel)
t.Views().Secondary().
IsFocused().
Title(Equals("Custom patch")).
SelectionIsActive().
Tap(func() {
t.GlobalPress(keys.Universal.Refresh)
}).
SelectionIsActive()
},
})
-29
View File
@@ -18,7 +18,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_author"
"github.com/jesseduffield/lazygit/pkg/integration/tests/filter_by_path"
"github.com/jesseduffield/lazygit/pkg/integration/tests/interactive_rebase"
"github.com/jesseduffield/lazygit/pkg/integration/tests/main_view"
"github.com/jesseduffield/lazygit/pkg/integration/tests/misc"
"github.com/jesseduffield/lazygit/pkg/integration/tests/patch_building"
"github.com/jesseduffield/lazygit/pkg/integration/tests/reflog"
@@ -229,7 +228,6 @@ var tests = []*components.IntegrationTest{
diff.DiffAndApplyPatch,
diff.DiffCommits,
diff.DiffNonStickyRange,
diff.DiffRendererMetadata,
diff.IgnoreWhitespace,
diff.RenameSimilarityThresholdChange,
diff.RenderThroughAPipe,
@@ -363,33 +361,6 @@ var tests = []*components.IntegrationTest{
interactive_rebase.SwapInRebaseWithConflictAndEdit,
interactive_rebase.SwapWithConflict,
interactive_rebase.ViewFilesOfTodoEntries,
main_view.ClickSelectsDiffLine,
main_view.DragRangeWithAutoscroll,
main_view.DragSelectsDiffLineRange,
main_view.EditSelectedDiffLine,
main_view.EscapeDismissesSelection,
main_view.FileNavigationScrollsToTheTop,
main_view.HideSelectionWhenChangesVanish,
main_view.KeepSelectionVisibleWhenDiffShrinks,
main_view.NavigateByHunkAndFile,
main_view.NoSelectionOverABinaryDiff,
main_view.NoSelectionOverACommitLog,
main_view.NoSelectionWhenNoChanges,
main_view.RangeSelectDiffLines,
main_view.SearchCollapsesTheSelection,
main_view.SearchFollowsTheSelection,
main_view.SelectBelowALongCommitMessage,
main_view.SelectBelowALongDiffstat,
main_view.SelectDiffLines,
main_view.SelectHunkBelowLastChange,
main_view.SelectHunkInDiff,
main_view.SelectHunkOnFocusingMainView,
main_view.SelectInADiffReadInPart,
main_view.SelectLineWhenWholeFileIsOneHunk,
main_view.SelectVisibleChangeOnFocusingMainView,
main_view.SelectVisibleHunkOnFocusingMainView,
main_view.SelectionCommandsOnlyWhereTheyApply,
main_view.SelectionOverTheCustomPatch,
misc.ConfirmOnQuit,
misc.CopyConfirmationMessageToClipboard,
misc.CopyToClipboard,
+3 -15
View File
@@ -212,20 +212,8 @@ func (self *ViewBufferManager) StartLoading() {
}
func (self *ViewBufferManager) ReadToEnd(then func()) {
self.readHoldingATask(-1, then)
}
// ReadLinesAndWait is ReadLines for lines lazygit is itself waiting on, rather than
// reading ahead of the user. It holds a gocui task until they have been read, so
// lazygit doesn't count as idle in the meantime (docs/dev/Busy.md).
func (self *ViewBufferManager) ReadLinesAndWait(totalLines int) {
self.readHoldingATask(totalLines, nil)
}
// readHoldingATask asks the task to have read totalLines lines in total (-1 for all of
// them) and calls then once it has. The reading happens on the task's own goroutine and
// the caller is waiting on the result, so lazygit must not count as idle in between.
func (self *ViewBufferManager) readHoldingATask(totalLines int, then func()) {
// The reading happens on the task's own goroutine, and the caller hears about
// it through then, so lazygit must not count as idle in between.
task := self.newGocuiTask()
answered := func() {
task.Done()
@@ -234,7 +222,7 @@ func (self *ViewBufferManager) readHoldingATask(totalLines int, then func()) {
}
}
request := LinesToRead{Total: totalLines, InitialRefreshAfter: -1, Then: answered}
request := LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: answered}
if !self.readRequests.enqueue(request) {
// With no task reading, everything there is to read has been read.
answered()
-28
View File
@@ -2461,34 +2461,6 @@
"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": [
{