mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-28 10:16:22 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c908a9461 |
@@ -845,8 +845,6 @@ keybinding:
|
||||
main:
|
||||
prevHunk: [<left>, h]
|
||||
nextHunk: [<right>, l]
|
||||
prevFile: "N"
|
||||
nextFile: "n"
|
||||
toggleSelectHunk: a
|
||||
pickBothHunks: b
|
||||
editSelectHunk: E
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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; {
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user