mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-03 20:46:47 -04:00
Compare commits
16
Commits
@@ -845,6 +845,8 @@ keybinding:
|
||||
main:
|
||||
prevHunk: [<left>, h]
|
||||
nextHunk: [<right>, l]
|
||||
prevFile: "N"
|
||||
nextFile: "n"
|
||||
toggleSelectHunk: a
|
||||
pickBothHunks: b
|
||||
editSelectHunk: E
|
||||
|
||||
@@ -223,6 +223,13 @@ _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 | |
|
||||
|
||||
@@ -328,6 +335,13 @@ _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,6 +192,13 @@ _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> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
@@ -305,6 +312,13 @@ _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,6 +84,13 @@ _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 | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
@@ -162,6 +169,13 @@ _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,6 +231,13 @@ _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 | |
|
||||
|
||||
@@ -306,6 +313,13 @@ _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,6 +99,13 @@ _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 | |
|
||||
|
||||
@@ -201,6 +208,13 @@ _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,6 +235,13 @@ _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 | |
|
||||
|
||||
@@ -337,6 +344,13 @@ _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,6 +74,13 @@ _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 | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
@@ -106,6 +113,13 @@ _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,6 +286,13 @@ _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> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
@@ -334,6 +341,13 @@ _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,6 +75,13 @@ _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> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -356,6 +363,13 @@ _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> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
||||
@@ -672,6 +672,8 @@ type KeybindingCommitFilesConfig struct {
|
||||
type KeybindingMainConfig struct {
|
||||
PrevHunk Keybinding `yaml:"prevHunk"`
|
||||
NextHunk Keybinding `yaml:"nextHunk"`
|
||||
PrevFile Keybinding `yaml:"prevFile"`
|
||||
NextFile Keybinding `yaml:"nextFile"`
|
||||
ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"`
|
||||
PickBothHunks Keybinding `yaml:"pickBothHunks"`
|
||||
EditSelectHunk Keybinding `yaml:"editSelectHunk"`
|
||||
@@ -1182,6 +1184,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
Main: KeybindingMainConfig{
|
||||
PrevHunk: Keybinding{"<left>", "h"},
|
||||
NextHunk: Keybinding{"<right>", "l"},
|
||||
PrevFile: Keybinding{"N"},
|
||||
NextFile: Keybinding{"n"},
|
||||
ToggleSelectHunk: Keybinding{"a"},
|
||||
PickBothHunks: Keybinding{"b"},
|
||||
EditSelectHunk: Keybinding{"E"},
|
||||
|
||||
+26
-1
@@ -164,6 +164,12 @@ 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
|
||||
|
||||
@@ -621,6 +627,12 @@ 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
|
||||
}
|
||||
@@ -863,7 +875,8 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isW
|
||||
rangeSelectEnd = max(relativeRangeSelectStart, v.cy)
|
||||
}
|
||||
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd {
|
||||
colorWidth := v.SelectedLineColorWidth
|
||||
if y >= rangeSelectStart && y <= rangeSelectEnd && (colorWidth == 0 || x < colorWidth) {
|
||||
fgColor = applySelTextColor(fgColor, v.SelTextColor)
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
@@ -2063,6 +2076,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -2272,6 +2288,15 @@ 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()
|
||||
|
||||
+14
-3
@@ -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,3 +399,14 @@ 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,15 +13,14 @@ 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
|
||||
onClickFocusedMainViewFn onClickFocusedMainViewFn
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
keybindingsFns []types.KeybindingsFn
|
||||
mouseKeybindingsFns []types.MouseKeybindingsFn
|
||||
onDoubleClickFn func() error
|
||||
onClickFn func(opts gocui.ViewMouseBindingOpts) error
|
||||
onRenderToMainFn func()
|
||||
onFocusFns []onFocusFn
|
||||
onFocusLostFns []onFocusLostFn
|
||||
onQuitFns []func()
|
||||
|
||||
focusable bool
|
||||
transient bool
|
||||
@@ -34,9 +33,8 @@ type BaseContext struct {
|
||||
}
|
||||
|
||||
type (
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error
|
||||
onFocusFn = func(types.OnFocusOpts)
|
||||
onFocusLostFn = func(types.OnFocusLostOpts)
|
||||
)
|
||||
|
||||
var _ types.IBaseContext = &BaseContext{}
|
||||
@@ -122,6 +120,14 @@ 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
|
||||
}
|
||||
@@ -153,7 +159,6 @@ func (self *BaseContext) ClearAllAttachedControllerFunctions() {
|
||||
self.onQuitFns = nil
|
||||
self.onDoubleClickFn = nil
|
||||
self.onClickFn = nil
|
||||
self.onClickFocusedMainViewFn = nil
|
||||
self.onRenderToMainFn = nil
|
||||
}
|
||||
|
||||
@@ -175,15 +180,6 @@ 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
|
||||
}
|
||||
@@ -192,10 +188,6 @@ 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,11 +19,16 @@ type CommitFilesContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IListContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffableContext = (*CommitFilesContext)(nil)
|
||||
_ types.IFilterableContext = (*CommitFilesContext)(nil)
|
||||
_ types.DiffMainViewContext = (*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,11 +31,16 @@ type commitDropIndicator struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.IListContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*LocalCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*LocalCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *LocalCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
|
||||
@@ -8,9 +8,62 @@ 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)
|
||||
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
|
||||
}
|
||||
|
||||
func NewMainContext(
|
||||
view *gocui.View,
|
||||
@@ -21,12 +74,11 @@ func NewMainContext(
|
||||
ctx := &MainContext{
|
||||
SimpleContext: NewSimpleContext(
|
||||
NewBaseContext(NewBaseContextOpts{
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
HasSelectableContent: false,
|
||||
Kind: types.MAIN_CONTEXT,
|
||||
View: view,
|
||||
WindowName: windowName,
|
||||
Key: key,
|
||||
Focusable: true,
|
||||
})),
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
}
|
||||
@@ -38,5 +90,8 @@ 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,10 +14,15 @@ type ReflogCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.IListContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*ReflogCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*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,10 +12,15 @@ type StashContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.IListContext = (*StashContext)(nil)
|
||||
_ types.DiffableContext = (*StashContext)(nil)
|
||||
_ types.DiffMainViewContext = (*StashContext)(nil)
|
||||
)
|
||||
|
||||
func (self *StashContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewStashContext(
|
||||
c *ContextCommon,
|
||||
) *StashContext {
|
||||
|
||||
@@ -21,11 +21,16 @@ type SubCommitsContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.IListContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffableContext = (*SubCommitsContext)(nil)
|
||||
_ types.ISearchableContext = (*SubCommitsContext)(nil)
|
||||
_ types.DiffMainViewContext = (*SubCommitsContext)(nil)
|
||||
)
|
||||
|
||||
func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType {
|
||||
return types.DiffMainViewTypePatchBuilding
|
||||
}
|
||||
|
||||
func NewSubCommitsContext(
|
||||
c *ContextCommon,
|
||||
) *SubCommitsContext {
|
||||
|
||||
@@ -15,10 +15,15 @@ type WorkingTreeContext struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IListContext = (*WorkingTreeContext)(nil)
|
||||
_ types.IFilterableContext = (*WorkingTreeContext)(nil)
|
||||
_ types.DiffMainViewContext = (*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 },
|
||||
|
||||
@@ -178,7 +178,6 @@ 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)
|
||||
@@ -315,13 +314,11 @@ 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,7 +8,6 @@ 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,10 +19,6 @@ 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,16 +606,6 @@ 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,16 +408,6 @@ 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.
|
||||
|
||||
@@ -45,13 +45,8 @@ func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (
|
||||
}
|
||||
|
||||
if renderingStatesDiffLines(contents) {
|
||||
// A row can carry more than one record, when the rendering puts two diff
|
||||
// lines on it; the first one is the row's identity, and the leftmost record
|
||||
// is the one a reader would call the row's own.
|
||||
if metadata := contents[bufferLineIdx].Metadata; len(metadata) > 0 {
|
||||
if parsed, ok := parseDiffLineMetadata(metadata[0]); ok {
|
||||
return self.diffLineInfo(parsed), true
|
||||
}
|
||||
if info, ok := self.diffLineInfoFromRecords(contents[bufferLineIdx].Metadata); ok {
|
||||
return info, true
|
||||
}
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
@@ -64,6 +59,56 @@ func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ 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 {
|
||||
@@ -12,6 +14,9 @@ type MainViewController struct {
|
||||
|
||||
context *context.MainContext
|
||||
otherContext *context.MainContext
|
||||
|
||||
dragAutoscroller *helpers.DragAutoscroller
|
||||
draggingWithMouse bool
|
||||
}
|
||||
|
||||
var _ types.IController = &MainViewController{}
|
||||
@@ -21,12 +26,19 @@ func NewMainViewController(
|
||||
context *context.MainContext,
|
||||
otherContext *context.MainContext,
|
||||
) *MainViewController {
|
||||
return &MainViewController{
|
||||
controller := &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 {
|
||||
@@ -38,10 +50,68 @@ 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,
|
||||
},
|
||||
{
|
||||
@@ -51,6 +121,28 @@ 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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +160,19 @@ 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,35 +181,691 @@ func (self *MainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *MainViewController) togglePanel() error {
|
||||
if self.otherContext.GetView().Visible {
|
||||
self.c.Context().Push(self.otherContext, types.OnFocusOpts{})
|
||||
if !self.otherContext.GetView().Visible {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
sidePanelContext := self.c.Context().NextInStack(self.context)
|
||||
if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil {
|
||||
return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y)
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
// 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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -61,21 +62,24 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context {
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
return self.focusMainView(self.c.Contexts().Normal, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error {
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary)
|
||||
return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) handleFocusMainView() error {
|
||||
return self.focusMainView(self.c.Contexts().Normal)
|
||||
return self.focusMainView(self.c.Contexts().Normal, -1)
|
||||
}
|
||||
|
||||
func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error {
|
||||
if context, ok := mainViewContext.(types.ISearchableContext); ok {
|
||||
context.ClearSearchString()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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
|
||||
}
|
||||
+153
-1
@@ -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 {
|
||||
opts.Pair.Secondary.GetView().Clear()
|
||||
gui.clearMainView(opts.Pair.Secondary)
|
||||
}
|
||||
|
||||
// Reset the scroll positions of all the other main views. We do this after
|
||||
@@ -137,6 +137,158 @@ 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
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ 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)
|
||||
})
|
||||
}
|
||||
@@ -115,6 +116,7 @@ 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)
|
||||
})
|
||||
}
|
||||
@@ -133,6 +135,7 @@ 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)
|
||||
})
|
||||
}
|
||||
@@ -160,10 +163,20 @@ 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, 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()
|
||||
// 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
|
||||
})
|
||||
},
|
||||
func() {
|
||||
// The content is fully loaded now, so let the scrollbar track it
|
||||
@@ -180,13 +193,22 @@ 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,
|
||||
view.SwapInOffscreenRender,
|
||||
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)
|
||||
},
|
||||
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,6 +78,7 @@ 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
|
||||
@@ -102,9 +103,6 @@ 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)
|
||||
@@ -180,6 +178,48 @@ 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
|
||||
|
||||
@@ -275,10 +315,6 @@ 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 {
|
||||
@@ -339,6 +375,8 @@ 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))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,14 +133,6 @@ 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.
|
||||
@@ -154,7 +146,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,6 +168,15 @@ 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,6 +310,7 @@ type TranslationSet struct {
|
||||
DiscardSelectionTooltip string
|
||||
ToggleSelectHunk string
|
||||
SelectHunk string
|
||||
NothingToSelectInDiff string
|
||||
SelectLineByLine string
|
||||
ToggleSelectHunkTooltip string
|
||||
HunkStagingHint string
|
||||
@@ -410,6 +411,8 @@ type TranslationSet struct {
|
||||
AskQuestion string
|
||||
PrevHunk string
|
||||
NextHunk string
|
||||
PrevFileInDiff string
|
||||
NextFileInDiff string
|
||||
PrevConflict string
|
||||
NextConflict string
|
||||
SelectPrevHunk string
|
||||
@@ -1493,6 +1496,7 @@ 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.",
|
||||
@@ -1598,6 +1602,8 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AskQuestion: "Ask Question",
|
||||
PrevHunk: "Go to previous hunk",
|
||||
NextHunk: "Go to next hunk",
|
||||
PrevFileInDiff: "Go to previous file",
|
||||
NextFileInDiff: "Go to next file",
|
||||
PrevConflict: "Previous conflict",
|
||||
NextConflict: "Next conflict",
|
||||
SelectPrevHunk: "Previous hunk",
|
||||
|
||||
@@ -174,6 +174,24 @@ func (self *ViewDriver) ContainsViewLines(matchers ...*TextMatcher) *ViewDriver
|
||||
return self
|
||||
}
|
||||
|
||||
// Asserts on the line the view shows at the top of its viewport, i.e. on where the
|
||||
// view is scrolled to. It is a view line, so a wrapped line above it doesn't throw the
|
||||
// count off.
|
||||
func (self *ViewDriver) TopVisibleLine(matcher *TextMatcher) *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
view := self.getView()
|
||||
lines := view.ViewBufferLines()
|
||||
originY := view.OriginY()
|
||||
if originY >= len(lines) {
|
||||
return false, fmt.Sprintf("%s: the view is scrolled to line %d, but it has only %d lines",
|
||||
self.context, originY, len(lines))
|
||||
}
|
||||
return matcher.context(fmt.Sprintf("%s top visible line", self.context)).test(lines[originY])
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// asserts that somewhere in the view there are consecutive lines matching the given matchers.
|
||||
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
self.validateMatchersPassed(matchers)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
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"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FileNavigationScrollsToTheTop = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Going to a file of a diff brings it to the top of the view, unless it is on screen already",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
Width: 100,
|
||||
Height: 20,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// More context than fits on screen, so that a long file's change is further
|
||||
// down than a screenful from the header naming the file.
|
||||
config.GetUserConfig().Git.DiffContextSize = 30
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = fmt.Sprintf("line%03d", i+1)
|
||||
}
|
||||
long := strings.Join(lines, "\n") + "\n"
|
||||
longChanged := strings.Replace(long, "line100", "LINE100", 1)
|
||||
|
||||
// Two long files with two short ones between them: the short ones are on screen
|
||||
// together, and there is enough diff below them to scroll to.
|
||||
shell.CreateFileAndAdd("aaa.txt", long)
|
||||
shell.CreateFileAndAdd("bbb.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ccc.txt", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("ddd.txt", long)
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("aaa.txt", longChanged)
|
||||
shell.UpdateFileAndAdd("bbb.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ccc.txt", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("ddd.txt", longChanged)
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// A file below the viewport becomes the top of it, so that as much of the file
|
||||
// as possible is on screen.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectionIsActive().
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/bbb.txt b/bbb.txt"),
|
||||
).
|
||||
// The next file is on screen already, so the view stays where it is and
|
||||
// only the selection moves.
|
||||
Press(keys.Main.NextFile).
|
||||
TopVisibleLine(Contains("diff --git a/bbb.txt b/bbb.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/ccc.txt b/ccc.txt"),
|
||||
).
|
||||
// Going back to a file above the viewport brings that one to the top.
|
||||
Press(keys.Main.PrevFile).
|
||||
Press(keys.Main.PrevFile).
|
||||
TopVisibleLine(Contains("diff --git a/aaa.txt b/aaa.txt")).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/aaa.txt b/aaa.txt"),
|
||||
).
|
||||
// In hunk mode the selection is the file's first change rather than the row
|
||||
// the file begins at, and with this much context that change is further down
|
||||
// than a screenful. The selection has to be on screen, so the alignment gives
|
||||
// way and the selection is scrolled into view as any other jump's is.
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("-line100"),
|
||||
Contains("+LINE100"),
|
||||
).
|
||||
SelectedLineIsVisible()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var NavigateByHunkAndFile = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Jump from hunk to hunk and from file to file in the focused main view of a commit's diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
|
||||
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n")
|
||||
shell.UpdateFileAndAdd("file2", "one\ntwo\nTHREE\n")
|
||||
shell.Commit("two")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("two").IsSelected(),
|
||||
Contains("one"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// Hunk navigation moves between change blocks. A hunk, in lazygit's sense,
|
||||
// is a run of changes bounded by context; one @@ hunk may hold several.
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
).
|
||||
// File navigation lands on the top of the next file's diff, which for a
|
||||
// parseable diff is its header.
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.NextFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file2 b/file2"),
|
||||
).
|
||||
Press(keys.Main.PrevFile).
|
||||
SelectedLines(
|
||||
Contains("diff --git a/file1 b/file1"),
|
||||
).
|
||||
// A range that only grows while shift is held is a plain selection again once
|
||||
// we jump elsewhere, rather than stretching to wherever we land.
|
||||
Press(keys.Main.NextHunk).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
).
|
||||
Press(keys.Main.NextHunk).
|
||||
SelectedLines(
|
||||
Contains("-nine"),
|
||||
).
|
||||
// A sticky range does stretch to it.
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
Press(keys.Main.PrevHunk).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
Contains(" four"),
|
||||
Contains(" five"),
|
||||
Contains(" six"),
|
||||
Contains(" seven"),
|
||||
Contains(" eight"),
|
||||
Contains("-nine"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
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"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
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"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
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,6 +18,7 @@ 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"
|
||||
@@ -363,6 +364,33 @@ 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,
|
||||
|
||||
+15
-3
@@ -212,8 +212,20 @@ func (self *ViewBufferManager) StartLoading() {
|
||||
}
|
||||
|
||||
func (self *ViewBufferManager) ReadToEnd(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.
|
||||
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()) {
|
||||
task := self.newGocuiTask()
|
||||
answered := func() {
|
||||
task.Done()
|
||||
@@ -222,7 +234,7 @@ func (self *ViewBufferManager) ReadToEnd(then func()) {
|
||||
}
|
||||
}
|
||||
|
||||
request := LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: answered}
|
||||
request := LinesToRead{Total: totalLines, InitialRefreshAfter: -1, Then: answered}
|
||||
if !self.readRequests.enqueue(request) {
|
||||
// With no task reading, everything there is to read has been read.
|
||||
answered()
|
||||
|
||||
@@ -2461,6 +2461,34 @@
|
||||
"l"
|
||||
]
|
||||
},
|
||||
"prevFile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "N"
|
||||
},
|
||||
"nextFile": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
],
|
||||
"default": "n"
|
||||
},
|
||||
"toggleSelectHunk": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user