diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index f19d74964..b5956295e 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -223,6 +223,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | Scroll down | | | `` (fn+down) `` | Scroll up | | | `` `` | 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 | | | `` `` | 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 | |-----|--------|-------------| | `` `` | 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 | | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 6a3d9b5c1..b6c7bd034 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -192,6 +192,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | @@ -305,6 +307,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | 下にスクロール | | | `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 085c8e3fc..5768e496c 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -84,6 +84,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 드래그 선택 전환 | | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | @@ -162,6 +164,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | 아래로 스크롤 | | | `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 드래그 선택 전환 | | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 524eddcab..2b37042c5 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -231,6 +231,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | Scroll omlaag | | | `` (fn+down) `` | Scroll omhoog | | | `` `` | 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 | | | `` `` | 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 | |-----|--------|-------------| | `` `` | 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 | | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index ba46f7c46..9caf2388b 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -99,6 +99,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 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 | | | `` `` | 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 | `` (fn+up) `` | Przewiń w dół | | | `` (fn+down) `` | Przewiń w górę | | | `` `` | 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 | | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 3071613be..32fe874b2 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -235,6 +235,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | Rolar para baixo | | | `` (fn+down) `` | Rolar para cima | | | `` `` | 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 | | | `` `` | 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 | |-----|--------|-------------| | `` `` | 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 | | | `` `` | Exit back to side panel | | | `` / `` | Pesquisar na visualização atual por texto | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index f6304dda9..33a49e396 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -74,6 +74,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Переключить выборку перетаскивания | | | `` `` | Exit back to side panel | | | `` / `` | Найти | | @@ -106,6 +108,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | Прокрутить вниз | | | `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Переключить выборку перетаскивания | | | `` `` | Exit back to side panel | | | `` / `` | Найти | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9819cb982..91226ebc1 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -286,6 +286,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | @@ -334,6 +336,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | 向下滚动 | | | `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 0e5debfdf..68a881f17 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -75,6 +75,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+up) `` | 向下捲動 | | | `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | +| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | +| `` v `` | 切換拖曳選擇 | | | `` `` | 退出回到側邊面板 | | | `` / `` | 搜尋 | | @@ -356,6 +358,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 切換至另一個面板 (已預存/未預存更改) | 切換到其他檢視(已暫存/未暫存的變更)。 | +| `` a `` | 切換程式碼塊選擇 | 切換逐行選擇與程式碼塊選擇模式。 | +| `` v `` | 切換拖曳選擇 | | | `` `` | 退出回到側邊面板 | | | `` / `` | 搜尋 | | diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 060079f50..fd9f6da55 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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() diff --git a/pkg/gui/context.go b/pkg/gui/context.go index d83b144f2..9e9c44d03 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -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() + }) +} diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index b36600674..8a2e8a55e 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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 } diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 692c6dd5c..b3e7ab21c 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -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() } diff --git a/pkg/gui/controllers/helpers/diff_line_helper.go b/pkg/gui/controllers/helpers/diff_line_helper.go index be96e4912..212cbff0b 100644 --- a/pkg/gui/controllers/helpers/diff_line_helper.go +++ b/pkg/gui/controllers/helpers/diff_line_helper.go @@ -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. diff --git a/pkg/gui/controllers/helpers/diff_line_queries.go b/pkg/gui/controllers/helpers/diff_line_queries.go new file mode 100644 index 000000000..478adcab2 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_queries.go @@ -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 +} diff --git a/pkg/gui/controllers/helpers/diff_line_queries_test.go b/pkg/gui/controllers/helpers/diff_line_queries_test.go new file mode 100644 index 000000000..79a9ab699 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_queries_test.go @@ -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) + } + }) + } +} diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 70e287f40..53154745d 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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 }) }) diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index a44d2dd1d..70d222664 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -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 } diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 1cf433209..4a3a42491 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -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 } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 7a67f4447..54903d1c9 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -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 diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 788184f47..c787d7504 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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)) diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 3d924a566..9d70b6e4b 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -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) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 007038e35..725dd5fe1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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.", diff --git a/pkg/integration/tests/main_view/click_selects_diff_line.go b/pkg/integration/tests/main_view/click_selects_diff_line.go new file mode 100644 index 000000000..830b11e54 --- /dev/null +++ b/pkg/integration/tests/main_view/click_selects_diff_line.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/escape_dismisses_selection.go b/pkg/integration/tests/main_view/escape_dismisses_selection.go new file mode 100644 index 000000000..4d7f091c4 --- /dev/null +++ b/pkg/integration/tests/main_view/escape_dismisses_selection.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go b/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go new file mode 100644 index 000000000..c9b15c982 --- /dev/null +++ b/pkg/integration/tests/main_view/hide_selection_when_changes_vanish.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go new file mode 100644 index 000000000..0e8690a0c --- /dev/null +++ b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go b/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go new file mode 100644 index 000000000..332b52b14 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_over_a_binary_diff.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go b/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go new file mode 100644 index 000000000..54f392822 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_over_a_commit_log.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/no_selection_when_no_changes.go b/pkg/integration/tests/main_view/no_selection_when_no_changes.go new file mode 100644 index 000000000..39d0871f9 --- /dev/null +++ b/pkg/integration/tests/main_view/no_selection_when_no_changes.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/range_select_diff_lines.go b/pkg/integration/tests/main_view/range_select_diff_lines.go new file mode 100644 index 000000000..85e02ec60 --- /dev/null +++ b/pkg/integration/tests/main_view/range_select_diff_lines.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/search_collapses_the_selection.go b/pkg/integration/tests/main_view/search_collapses_the_selection.go new file mode 100644 index 000000000..e36fd36f2 --- /dev/null +++ b/pkg/integration/tests/main_view/search_collapses_the_selection.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_below_a_long_commit_message.go b/pkg/integration/tests/main_view/select_below_a_long_commit_message.go new file mode 100644 index 000000000..45303f881 --- /dev/null +++ b/pkg/integration/tests/main_view/select_below_a_long_commit_message.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/select_below_a_long_diffstat.go b/pkg/integration/tests/main_view/select_below_a_long_diffstat.go new file mode 100644 index 000000000..865035976 --- /dev/null +++ b/pkg/integration/tests/main_view/select_below_a_long_diffstat.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/select_diff_lines.go b/pkg/integration/tests/main_view/select_diff_lines.go new file mode 100644 index 000000000..7e5573518 --- /dev/null +++ b/pkg/integration/tests/main_view/select_diff_lines.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/select_hunk_below_last_change.go b/pkg/integration/tests/main_view/select_hunk_below_last_change.go new file mode 100644 index 000000000..b8f6b90c8 --- /dev/null +++ b/pkg/integration/tests/main_view/select_hunk_below_last_change.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_hunk_in_diff.go b/pkg/integration/tests/main_view/select_hunk_in_diff.go new file mode 100644 index 000000000..e57e26185 --- /dev/null +++ b/pkg/integration/tests/main_view/select_hunk_in_diff.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go b/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go new file mode 100644 index 000000000..9299c984d --- /dev/null +++ b/pkg/integration/tests/main_view/select_in_a_diff_read_in_part.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go b/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go new file mode 100644 index 000000000..5b006d938 --- /dev/null +++ b/pkg/integration/tests/main_view/select_visible_change_on_focusing_main_view.go @@ -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) + }, +}) diff --git a/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go new file mode 100644 index 000000000..afbd59f5e --- /dev/null +++ b/pkg/integration/tests/main_view/selection_commands_only_where_they_apply.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/main_view/selection_over_the_custom_patch.go b/pkg/integration/tests/main_view/selection_over_the_custom_patch.go new file mode 100644 index 000000000..eec447af2 --- /dev/null +++ b/pkg/integration/tests/main_view/selection_over_the_custom_patch.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d699d8cb6..1fe47c203 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index f9b309c3d..d446872df 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -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.