mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-04 13:06:51 -04:00
Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no way to say "this line" or "these lines", which is what every line-level interaction needs — editing a line, copying part of a diff, and, later, staging. So a diff main view now always carries a selection while focused, starting at the first change already on screen so that focusing doesn't move the view. The mode of the selection lives on the main context (a single line, a range from a fixed anchor, or the change block around the cursor); the selected line and the range anchor stay in the view itself, whose native range select draws them, so there is no new highlight machinery. The modes and their keys are the ones the staging view has, including that the arrow keys step from block to block in hunk mode. A pane holds something to select only when what it is showing is a diff with changes in it, which the render works out once its content is final: a placeholder message is not a diff, and neither is a diff with nothing in it at all, such as a binary file's or an empty commit's. Whether a selection is then drawn there follows from the context stack, as it does for every other view. A click in the focused main view now has a meaning again: it selects the line it points at. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81c27b2ff8
commit
6a6683729f
@@ -223,6 +223,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
@@ -328,6 +330,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Search the current view by text | |
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ _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 `` | 範囲選択を切り替え | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
@@ -305,6 +307,8 @@ _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 `` | 範囲選択を切り替え | |
|
||||
| `` <esc> `` | サイドパネルに戻る | |
|
||||
| `` / `` | 現在のビューをテキストで検索 | |
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ _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 `` | 드래그 선택 전환 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
@@ -162,6 +164,8 @@ _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 `` | 드래그 선택 전환 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | 검색 시작 | |
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
@@ -306,6 +308,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Start met zoeken | |
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
@@ -201,6 +203,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Szukaj w bieżącym widoku po tekście | |
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
@@ -337,6 +339,8 @@ _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 | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Pesquisar na visualização atual por texto | |
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ _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 `` | Переключить выборку перетаскивания | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
@@ -106,6 +108,8 @@ _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 `` | Переключить выборку перетаскивания | |
|
||||
| `` <esc> `` | Exit back to side panel | |
|
||||
| `` / `` | Найти | |
|
||||
|
||||
|
||||
@@ -286,6 +286,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
|
||||
| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 |
|
||||
| `` v `` | 切换拖动选择 | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
@@ -334,6 +336,8 @@ _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 `` | 切换拖动选择 | |
|
||||
| `` <esc> `` | 退出回到侧边面板 | |
|
||||
| `` / `` | 开始搜索 | |
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ _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 `` | 切換拖曳選擇 | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
@@ -356,6 +358,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 |
|
||||
| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 |
|
||||
| `` v `` | 切換拖曳選擇 | |
|
||||
| `` <esc> `` | 退出回到側邊面板 | |
|
||||
| `` / `` | 搜尋 | |
|
||||
|
||||
|
||||
@@ -2275,6 +2275,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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,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
|
||||
}
|
||||
|
||||
@@ -8,10 +8,71 @@ import (
|
||||
type MainContext struct {
|
||||
*SimpleContext
|
||||
*SearchTrait
|
||||
|
||||
diffSelect DiffSelectState
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DiffSelectState returns the focused main view's selection mode state, for the
|
||||
// controllers to read and mutate directly.
|
||||
func (self *MainContext) DiffSelectState() *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 = DiffSelectModeLine
|
||||
self.diffSelect.RangeIsSticky = false
|
||||
self.diffSelect.UserEnabledHunkMode = false
|
||||
self.GetView().CancelRangeSelect()
|
||||
}
|
||||
|
||||
// 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,
|
||||
windowName string,
|
||||
@@ -21,12 +82,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 +98,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()
|
||||
}
|
||||
|
||||
@@ -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,179 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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,49 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type MainViewController struct {
|
||||
@@ -38,10 +39,32 @@ 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 == context.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.Return),
|
||||
Handler: self.escape,
|
||||
Description: self.c.Tr.ExitFocusedMainView,
|
||||
DescriptionFunc: self.escapeDescription,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
@@ -53,6 +76,22 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
},
|
||||
{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},
|
||||
@@ -62,6 +101,12 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
|
||||
func (self *MainViewController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
return []*gocui.ViewMouseBinding{
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
Handler: self.onClickInAlreadyFocusedView,
|
||||
FocusedView: self.context.GetViewName(),
|
||||
},
|
||||
{
|
||||
ViewName: self.context.GetViewName(),
|
||||
Key: gocui.MouseLeft,
|
||||
@@ -76,27 +121,398 @@ 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) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: 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 == context.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() *context.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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// selectClickedDiffLine sets the focused main view's selection from a click at the
|
||||
// given view line. In hunk mode a click on a change line keeps hunk mode and selects
|
||||
// that whole block, so clicking from hunk to hunk stays ready to act on one; a click
|
||||
// on context drops to a single line, as does any click when we weren't in hunk mode —
|
||||
// the click points at that line precisely, e.g. to edit it.
|
||||
func (self *MainViewController) selectClickedDiffLine(viewLine int) {
|
||||
if !self.isDiffView() {
|
||||
return
|
||||
}
|
||||
view := self.context.GetView()
|
||||
if self.diffSelectState().Mode == context.DiffSelectModeHunk &&
|
||||
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.
|
||||
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 {
|
||||
showSelectionAtLine(view, clickedViewLine, false)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := c.Helpers().DiffLine.FirstChangeLineInView(view)
|
||||
if !ok {
|
||||
showSelectionAtLine(view, view.MiddleVisibleLineIdx(), false)
|
||||
return
|
||||
}
|
||||
showSelectionAtLine(view, target, false)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// selectHunkAround 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 (self *MainViewController) selectHunkAround(changeViewLine int, scrollIntoView bool) {
|
||||
view := self.context.GetView()
|
||||
start, end, ok := self.c.Helpers().DiffLine.ChangeBlockBounds(view, changeViewLine)
|
||||
if !ok {
|
||||
self.diffSelectState().Mode = context.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.
|
||||
func (self *MainViewController) navigate(find findDiffRowFn, forward 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)
|
||||
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)
|
||||
}
|
||||
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) placeNavigationTarget(target int) {
|
||||
v := self.context.GetView()
|
||||
if !v.Highlight {
|
||||
v.SetOrigin(0, target)
|
||||
return
|
||||
}
|
||||
if self.diffSelectState().Mode == context.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)
|
||||
}
|
||||
|
||||
// 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 == context.DiffSelectModeHunk ||
|
||||
(sel.Mode == context.DiffSelectModeRange && !sel.RangeIsSticky) {
|
||||
sel.Mode = context.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 == context.DiffSelectModeHunk && (delta == 1 || delta == -1) {
|
||||
self.navigate(self.c.Helpers().DiffLine.AdjacentChangeBlock, delta > 0)
|
||||
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 != context.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 == context.DiffSelectModeHunk {
|
||||
sel.Mode = context.DiffSelectModeLine
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = context.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 = context.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
v.CancelRangeSelect()
|
||||
} else {
|
||||
sel.Mode = context.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 = context.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 {
|
||||
@@ -108,28 +524,32 @@ func (self *MainViewController) handleLineChange(delta int) {
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevLine() error {
|
||||
self.handleLineChange(-1)
|
||||
self.adjustSelection(-1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextLine() error {
|
||||
self.handleLineChange(1)
|
||||
self.adjustSelection(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handlePrevPage() error {
|
||||
self.handleLineChange(-self.context.GetViewTrait().PageDelta())
|
||||
self.adjustSelection(-self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleNextPage() error {
|
||||
self.handleLineChange(self.context.GetViewTrait().PageDelta())
|
||||
self.adjustSelection(self.context.GetViewTrait().PageDelta())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) handleGotoTop() error {
|
||||
v := self.context.GetView()
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(-v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -138,7 +558,11 @@ func (self *MainViewController) handleGotoBottom() error {
|
||||
manager.ReadToEnd(func() {
|
||||
self.c.OnUIThread(func() error {
|
||||
v := self.context.GetView()
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
if !v.Highlight {
|
||||
self.handleLineChange(v.ViewLinesHeight())
|
||||
return nil
|
||||
}
|
||||
self.selectAbsoluteLine(v.ViewLinesHeight() - 1)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,19 +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 *context.MainContext) error {
|
||||
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
|
||||
}
|
||||
|
||||
+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
|
||||
@@ -365,6 +366,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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -310,6 +310,7 @@ type TranslationSet struct {
|
||||
DiscardSelectionTooltip string
|
||||
ToggleSelectHunk string
|
||||
SelectHunk string
|
||||
NothingToSelectInDiff string
|
||||
SelectLineByLine string
|
||||
ToggleSelectHunkTooltip string
|
||||
HunkStagingHint string
|
||||
@@ -1493,6 +1494,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.",
|
||||
|
||||
@@ -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,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,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,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,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,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,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,56 @@
|
||||
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"))
|
||||
}).
|
||||
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,24 @@ var tests = []*components.IntegrationTest{
|
||||
interactive_rebase.SwapInRebaseWithConflictAndEdit,
|
||||
interactive_rebase.SwapWithConflict,
|
||||
interactive_rebase.ViewFilesOfTodoEntries,
|
||||
main_view.ClickSelectsDiffLine,
|
||||
main_view.EscapeDismissesSelection,
|
||||
main_view.HideSelectionWhenChangesVanish,
|
||||
main_view.KeepSelectionVisibleWhenDiffShrinks,
|
||||
main_view.NoSelectionOverABinaryDiff,
|
||||
main_view.NoSelectionOverACommitLog,
|
||||
main_view.NoSelectionWhenNoChanges,
|
||||
main_view.RangeSelectDiffLines,
|
||||
main_view.SearchCollapsesTheSelection,
|
||||
main_view.SelectBelowALongCommitMessage,
|
||||
main_view.SelectBelowALongDiffstat,
|
||||
main_view.SelectDiffLines,
|
||||
main_view.SelectHunkBelowLastChange,
|
||||
main_view.SelectHunkInDiff,
|
||||
main_view.SelectInADiffReadInPart,
|
||||
main_view.SelectVisibleChangeOnFocusingMainView,
|
||||
main_view.SelectionCommandsOnlyWhereTheyApply,
|
||||
main_view.SelectionOverTheCustomPatch,
|
||||
misc.ConfirmOnQuit,
|
||||
misc.CopyConfirmationMessageToClipboard,
|
||||
misc.CopyToClipboard,
|
||||
|
||||
@@ -215,6 +215,13 @@ func (self *ViewBufferManager) ReadToEnd(then func()) {
|
||||
self.readHoldingATask(-1, then)
|
||||
}
|
||||
|
||||
// ReadLinesAndWait is ReadLines for lines lazygit is itself waiting on, rather than
|
||||
// reading ahead of the user. It holds a gocui task until they have been read, so
|
||||
// lazygit doesn't count as idle in the meantime (docs/dev/Busy.md).
|
||||
func (self *ViewBufferManager) ReadLinesAndWait(totalLines int) {
|
||||
self.readHoldingATask(totalLines, nil)
|
||||
}
|
||||
|
||||
// readHoldingATask asks the task to have read totalLines lines in total (-1 for all of
|
||||
// them) and calls then once it has. The reading happens on the task's own goroutine and
|
||||
// the caller is waiting on the result, so lazygit must not count as idle in between.
|
||||
|
||||
Reference in New Issue
Block a user