diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index fe18e7316..c361cefa0 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -225,6 +225,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Stage | Toggle selection staged / unstaged. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Toggle range select | | | `` , h `` | Go to previous hunk | | | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | @@ -335,6 +337,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Stage | Toggle selection staged / unstaged. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Toggle range select | | | `` , h `` | Go to previous hunk | | | `` , l `` | Go to next hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index fe4b5ccc2..ec6b53dee 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -194,6 +194,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | | `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | | `` , h `` | 前のハンクに移動 | | | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | @@ -312,6 +314,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | | `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | +| `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 範囲選択を切り替え | | | `` , h `` | 前のハンクに移動 | | | `` , l `` | 次のハンクに移動 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 97229aed7..0aff8c9d0 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -86,6 +86,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Staged 전환 | 선택한 행을 staged / unstaged | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 드래그 선택 전환 | | | `` , h `` | 이전 hunk를 선택 | | | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | @@ -169,6 +171,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Staged 전환 | 선택한 행을 staged / unstaged | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 드래그 선택 전환 | | | `` , h `` | 이전 hunk를 선택 | | | `` , l `` | 다음 hunk를 선택 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 4b24387d6..78897cde3 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -233,6 +233,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Toggle drag selecteer | | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | @@ -313,6 +315,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Toggle drag selecteer | | | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 5d2d0dd68..4d53a81c5 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -101,6 +101,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Przełącz zaznaczenie zakresu | | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | @@ -208,6 +210,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Przełącz zaznaczenie zakresu | | | `` , h `` | Idź do poprzedniego fragmentu | | | `` , l `` | Idź do następnego fragmentu | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 2e6f87549..c4916921a 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -237,6 +237,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | +| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | +| `` v `` | Toggle range select | | | `` , h `` | Ir para o local anterior | | | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | @@ -344,6 +346,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | +| `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | +| `` v `` | Toggle range select | | | `` , h `` | Ir para o local anterior | | | `` , l `` | Ir para o próximo trecho | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 527ff4cdd..4b4b9e38e 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -76,6 +76,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Переключить выборку перетаскивания | | | `` , h `` | Выбрать предыдущую часть | | | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | @@ -113,6 +115,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | Переключить выборку перетаскивания | | | `` , h `` | Выбрать предыдущую часть | | | `` , l `` | Выбрать следующую часть | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index ec174c05c..1869450b2 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -288,6 +288,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | | `` `` | 切换暂存状态 | 切换行暂存状态 | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | | `` , h `` | 选择上一个区块 | | | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | @@ -341,6 +343,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | | `` `` | 切换暂存状态 | 切换行暂存状态 | +| `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | +| `` v `` | 切换拖动选择 | | | `` , h `` | 选择上一个区块 | | | `` , l `` | 选择下一个区块 | | | `` N `` | Go to previous file | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 3605b5e91..31eee2750 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -84,6 +84,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 切換拖曳選擇 | | | `` , h `` | 選擇上一段 | | | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | @@ -370,6 +372,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | +| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` v `` | 切換拖曳選擇 | | | `` , h `` | 選擇上一段 | | | `` , l `` | 選擇下一段 | | | `` N `` | Go to previous file | | diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 047ac17c0..e4b759a78 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -38,7 +38,7 @@ type ( onFocusFn = func(types.OnFocusOpts) onFocusLostFn = func(types.OnFocusLostOpts) onClickFocusedMainViewFn = func(mainViewName string, clickedLineIdx int) error - onStageFocusedMainViewFn = func(mainViewName string, viewLineIdx int) error + onStageFocusedMainViewFn = func(mainViewName string, firstLineIdx int, lastLineIdx int) error ) var _ types.IBaseContext = &BaseContext{} diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index c8b6edade..10c3c445d 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -8,10 +8,46 @@ import ( type MainContext struct { *SimpleContext *SearchTrait + + diffSelect DiffSelectState } 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 rangeSelectStartY, i.e. native range select); only the mode lives +// here. It's on the context, not the controller, so that the main view controller +// (which drives the selection), the focus controller (which resets it on focus), +// and togglePanel (which sets it on the other pane) can all reach it via the +// context they already hold. +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 the user turned on hunk mode explicitly, as opposed to it being the + // configured default; this decides whether escape leaves hunk mode. + 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 +} + func NewMainContext( view *gocui.View, windowName string, diff --git a/pkg/gui/controllers/base_controller.go b/pkg/gui/controllers/base_controller.go index a936af94c..31dc3bc45 100644 --- a/pkg/gui/controllers/base_controller.go +++ b/pkg/gui/controllers/base_controller.go @@ -23,7 +23,7 @@ func (self *baseController) GetOnClickFocusedMainView() func(mainViewName string return nil } -func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error { +func (self *baseController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error { return nil } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index f0e2df951..298599797 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -457,8 +457,8 @@ func (self *FilesController) diffSplitState(node *filetree.FileNode) (split bool return split, mainShowsStaged } -func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error { - return func(mainViewName string, viewLineIdx int) error { +func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error { + return func(mainViewName string, firstLineIdx int, lastLineIdx int) error { if self.c.UserConfig().Git.DiffContextSize == 0 { return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) @@ -466,13 +466,13 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin node := self.context().GetSelected() if node == nil || !node.IsFile() { - // Staging a line of a multi-file (directory) diff is a later step; for - // now only single-file diffs are stageable from the focused main view. + // Staging from a multi-file (directory) diff is a later step; for now + // only single-file diffs are stageable from the focused main view. return nil } - info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, viewLineIdx) - if !ok { + infos := self.c.Helpers().Staging.ChangeLinesInViewRange(mainViewName, firstLineIdx, lastLineIdx) + if len(infos) == 0 { return nil } @@ -482,28 +482,57 @@ func (self *FilesController) GetOnStageFocusedMainView() func(mainViewName strin _, mainShowsStaged := self.diffSplitState(node) staged := mainShowsStaged || mainViewName == self.c.Contexts().NormalSecondary.GetViewName() - return self.stageDiffLine(node.File, info, staged) + return self.stageDiffLines(node.File, infos, staged) } } -// stageDiffLine stages, or when reverse is true unstages, the single diff line -// identified by info. It builds a one-line patch from the file's diff and applies -// it the same way the staging view does, but resolves the patch line from the -// diff-line metadata rather than from a patch-explorer selection. A context or -// header line yields an empty patch and is a no-op. -func (self *FilesController) stageDiffLine(file *models.File, info types.DiffLineInfo, reverse bool) error { +// stageDiffLines stages, or when reverse is true unstages, the diff lines identified +// by infos (a single line, a range, or a hunk). It builds one patch from the file's +// diff including all the selected change lines and applies it the same way the +// staging view does, but identifies the patch lines from the diff-line metadata +// rather than from a patch-explorer selection. A selection covering no change lines +// yields an empty patch and is a no-op. +// +// Each selected change line is keyed by its (file line number, deletion?) identity, +// and the freshly parsed patch is scanned for the body lines matching those +// identities. Scanning the patch and matching identities — rather than looking each +// line number up with PatchLineForLineNumber — is what makes a modified line work: a +// deletion and the addition replacing it share a position but have distinct +// identities, and the line-number lookup can't tell an addition at the start of a +// hunk from the deletion above it. +func (self *FilesController) stageDiffLines(file *models.File, infos []types.DiffLineInfo, reverse bool) error { parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, true, reverse)) - lineNumber, isDeletion := info.PatchSelectLine() - patchLineIdx := parsedPatch.PatchLineForLineNumber(lineNumber) - if isDeletion { - patchLineIdx = parsedPatch.PatchLineForOldLineNumber(lineNumber) + type changeLineKey struct { + lineNumber int + isDeletion bool + } + selected := make(map[changeLineKey]bool, len(infos)) + for _, info := range infos { + lineNumber, isDeletion := info.PatchSelectLine() + selected[changeLineKey{lineNumber, isDeletion}] = true + } + + var patchLineIndices []int + for idx, line := range parsedPatch.Lines() { + var key changeLineKey + switch { + case line.IsAddition(): + key = changeLineKey{parsedPatch.LineNumberOfLine(idx), false} + case line.IsDeletion(): + key = changeLineKey{parsedPatch.OldLineNumberOfLine(idx), true} + default: + continue + } + if selected[key] { + patchLineIndices = append(patchLineIndices, idx) + } } patchToApply := parsedPatch. Transform(patch.TransformOpts{ Reverse: reverse, - IncludedLineIndices: []int{patchLineIdx}, + IncludedLineIndices: patchLineIndices, FileNameOverride: file.GetPath(), }). FormatPlain() diff --git a/pkg/gui/controllers/helpers/diff_line_navigation.go b/pkg/gui/controllers/helpers/diff_line_navigation.go index 2f2302fb2..a448ceaa1 100644 --- a/pkg/gui/controllers/helpers/diff_line_navigation.go +++ b/pkg/gui/controllers/helpers/diff_line_navigation.go @@ -81,6 +81,60 @@ func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) { return 0, false } +// ChangeBlockBounds returns the view-line range [start, end] of the change block +// (lazygit's notion of a hunk; see AdjacentChangeBlock) to select when entering or +// moving in hunk mode in view's displayed diff. The block is the one containing +// anchorViewLine, or — when that line is context — the first block at or below it +// (matching how toggling hunk mode in the staging view snaps to the next change). +// ok is false when no change line lies at or below the anchor (e.g. scrolled into +// trailing context, or the diff isn't loaded that far yet). +func (self *StagingHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) { + anchor, ok := view.BufferLineForViewLine(anchorViewLine) + if !ok { + return 0, 0, false + } + + resolved := self.resolveDiffLines(view.DiffLineContents()) + isChange := make([]bool, len(resolved)) + for i, r := range resolved { + isChange[i] = r.ok && r.info.IsChange() + } + + // Snap to the first change line at or after the anchor, then expand over the + // whole contiguous run of change lines around it. + start := anchor + for start < len(isChange) && !isChange[start] { + start++ + } + if start >= len(isChange) { + return 0, 0, false + } + end := start + for start > 0 && isChange[start-1] { + start-- + } + for end < len(isChange)-1 && isChange[end+1] { + end++ + } + + startView, ok1 := view.ViewLineForBufferLine(start) + endView, ok2 := view.ViewLineForBufferLine(end) + if !ok1 || !ok2 { + return 0, 0, false + } + // ViewLineForBufferLine gives the first view line of the block's last buffer + // line; extend over any further view lines it wrapped to, so the highlight + // covers the whole block. + for endView < view.ViewLinesHeight()-1 { + next, ok := view.BufferLineForViewLine(endView + 1) + if !ok || next != end { + break + } + endView++ + } + return startView, endView, true +} + // 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, mirroring the staging view's diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index ac0894969..e1d52611d 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -163,6 +163,35 @@ func (self *StagingHelper) GetDiffLineInfo(windowName string, viewLineIdx int) ( return self.GetDiffLineInfoForView(v, viewLineIdx) } +// ChangeLinesInViewRange resolves every change line (addition/deletion) in the +// inclusive view-line range [first, last] of the diff shown in the given window, as +// the patch-space identities to stage. It is the range form of GetDiffLineInfo, +// behind staging a selection from the focused main view. Context and header lines +// are skipped (Transform emits context regardless of the included set, so only +// change lines need collecting — see §21.3), and view lines that wrap to the same +// buffer line are de-duplicated. +func (self *StagingHelper) ChangeLinesInViewRange(windowName string, first int, last int) []types.DiffLineInfo { + v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName)) + if v == nil { + return nil + } + + resolved := self.resolveDiffLines(v.DiffLineContents()) + var infos []types.DiffLineInfo + lastBufferLine := -1 + for viewLine := first; viewLine <= last; viewLine++ { + bufferLine, ok := v.BufferLineForViewLine(viewLine) + if !ok || bufferLine == lastBufferLine { + continue + } + lastBufferLine = bufferLine + if bufferLine < len(resolved) && resolved[bufferLine].ok && resolved[bufferLine].info.IsChange() { + infos = append(infos, resolved[bufferLine].info) + } + } + return infos +} + // GetDiffLineInfoForView is GetDiffLineInfo against a specific view rather than // one looked up by window. It is used to read the identity of the line the patch // explorer currently has selected when escaping back to the focused main view, diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 97669f6fd..fee7ad8f9 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -97,6 +97,24 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: openPullRequestDescription, Tooltip: openPullRequestTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk), + Handler: self.toggleSelectHunk, + Description: self.c.Tr.ToggleSelectHunk, + DescriptionFunc: func() string { + if self.sel().Mode == context.DiffSelectModeHunk { + return self.c.Tr.SelectLineByLine + } + return self.c.Tr.SelectHunk + }, + Tooltip: self.c.Tr.ToggleSelectHunkTooltip, + DisplayOnScreen: selectionShown, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), + Handler: self.toggleRangeSelect, + Description: self.c.Tr.ToggleRangeSelect, + }, { Keys: opts.GetKeys(opts.Config.Main.PrevHunk), Handler: self.prevChangeBlock, @@ -126,6 +144,8 @@ 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}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), Handler: self.extendRangeDown, Description: self.c.Tr.RangeSelectDown}, {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}, @@ -197,7 +217,7 @@ func (self *MainViewController) togglePanel() error { isDiff := self.isDiffView() self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) if isDiff { - showInitialDiffSelection(self.c, self.otherContext.GetView()) + showInitialDiffSelection(self.c, self.otherContext) } return nil } @@ -205,8 +225,11 @@ func (self *MainViewController) togglePanel() error { // showInitialDiffSelection turns on the focused main view's selection when entering // a diff view without pointing at a specific line: on the first change line already // visible (so the view doesn't jump), falling back to the current top line when none -// is visible (scrolled into trailing context, or not loaded that far yet). -func showInitialDiffSelection(c *ControllerCommon, view *gocui.View) { +// is visible (scrolled into trailing context, or not loaded that far yet). The +// select mode is reset to its default (a single line). +func showInitialDiffSelection(c *ControllerCommon, mainContext *context.MainContext) { + resetDiffSelectMode(mainContext) + view := mainContext.GetView() target, ok := c.Helpers().Staging.FirstChangeLineInView(view) if !ok { target = view.OriginY() @@ -214,6 +237,18 @@ func showInitialDiffSelection(c *ControllerCommon, view *gocui.View) { showSelectionAtLine(view, target, true) } +// resetDiffSelectMode returns the focused main view to its default select mode — a +// single line, no range — used whenever the selection is (re-)established from +// scratch (on focus, on a click). The view's range anchor is cleared too so the +// next render highlights only the cursor line. +func resetDiffSelectMode(mainContext *context.MainContext) { + sel := mainContext.DiffSelectState() + sel.Mode = context.DiffSelectModeLine + sel.RangeIsSticky = false + sel.UserEnabledHunkMode = false + mainContext.GetView().CancelRangeSelect() +} + func (self *MainViewController) escape() error { self.c.Context().Pop() return nil @@ -225,10 +260,11 @@ func (self *MainViewController) isDiffView() bool { return sidePanelShowsDiff(self.c.Context().NextInStack(self.context)) } -// stageSelectedLine stages (or unstages) the selected diff line, delegating to the -// side panel beneath the focused main view since what "stage" means is the panel's -// business (the working tree stages; later, commits add to a custom patch). Panels -// whose diff isn't stageable register no handler, so this is a no-op there. +// stageSelectedLine stages (or unstages) the selected diff line(s) — a single line, +// a range, or a hunk — delegating to the side panel beneath the focused main view +// since what "stage" means is the panel's business (the working tree stages; later, +// commits add to a custom patch). Panels whose diff isn't stageable register no +// handler, so this is a no-op there. func (self *MainViewController) stageSelectedLine() error { sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext == nil { @@ -238,7 +274,8 @@ func (self *MainViewController) stageSelectedLine() error { if handler == nil { return nil } - return handler(self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + first, last := self.context.GetView().SelectedLineRange() + return handler(self.context.GetViewName(), first, last) } func (self *MainViewController) enter() error { @@ -270,114 +307,276 @@ func showSelectionAtLine(view *gocui.View, lineIdx int, scrollIntoView bool) { view.FocusPoint(0, lineIdx, scrollIntoView) } +// sel returns our pane's diff selection mode state. +func (self *MainViewController) sel() *context.DiffSelectState { + return self.context.DiffSelectState() +} + // navigate jumps the focused main view by file or change block (hunk), using find to // locate the target row from the current anchor. The anchor is the selected line if a // selection is showing, otherwise the top visible line. With a selection showing we -// move it to the target and scroll it into view, like the staging view; with none we -// stay in scroll mode, bringing the target to the top without selecting anything. -func (self *MainViewController) navigate(find func(*gocui.View, int, bool) (int, bool), forward bool) error { +// move it to the target and scroll it into view, like the staging 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 func(*gocui.View, int, bool) (int, bool), forward bool) { v := self.context.GetView() - showSelection := v.Highlight - anchor := v.OriginY() - if showSelection { - anchor = v.SelectedLineIdx() + if !v.Highlight { + if target, ok := find(v, v.OriginY(), forward); ok { + v.SetOrigin(0, target) + } + return } - target, ok := find(v, anchor, forward) + target, ok := find(v, v.SelectedLineIdx(), forward) if !ok { + return + } + if self.sel().Mode == context.DiffSelectModeHunk { + self.selectHunkAround(target) + } else { + // Line mode leaves a single-line selection at the target; an active range + // extends to it, since the anchor is untouched. + showSelectionAtLine(v, target, true) + } +} + +func (self *MainViewController) nextChangeBlock() error { + self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, true) + return nil +} + +func (self *MainViewController) prevChangeBlock() error { + self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, false) + return nil +} + +func (self *MainViewController) nextFile() error { + self.navigate(self.c.Helpers().Staging.AdjacentFile, true) + return nil +} + +func (self *MainViewController) prevFile() error { + self.navigate(self.c.Helpers().Staging.AdjacentFile, false) + return nil +} + +// selectHunkAround re-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 change block +// there (the line is trailing context) it falls back to a single-line selection. +func (self *MainViewController) selectHunkAround(changeViewLine int) { + v := self.context.GetView() + start, end, ok := self.c.Helpers().Staging.ChangeBlockBounds(v, changeViewLine) + if !ok { + self.sel().Mode = context.DiffSelectModeLine + v.CancelRangeSelect() + showSelectionAtLine(v, changeViewLine, true) + return + } + v.SetRangeSelectStart(end) + showSelectionAtLine(v, start, 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 in a range this extends/contracts it; in +// line mode it just moves the selected line. +func (self *MainViewController) moveCursor(delta int) { + v := self.context.GetView() + if delta > 0 { + if manager := self.c.GetViewBufferManagerForView(v); manager != nil { + manager.ReadLines(delta) + } + } + 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) + } + v.FocusPoint(0, after, true) +} + +// scroll moves the view by delta lines without a selection, for non-diff main +// content where there's nothing to select. +func (self *MainViewController) scroll(delta int) { + v := self.context.GetView() + if delta > 0 { + if manager := self.c.GetViewBufferManagerForView(v); manager != nil { + manager.ReadLines(delta) + } + v.ScrollDown(delta) + } else { + v.ScrollUp(-delta) + } +} + +// 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.sel() + 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 (delta ±1) jumps to the adjacent hunk; a +// larger page step drops out of hunk mode first, like the staging view. 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) { + v := self.context.GetView() + if !v.Highlight { + self.scroll(delta) + return + } + if self.sel().Mode == context.DiffSelectModeHunk && (delta == 1 || delta == -1) { + self.navigate(self.c.Helpers().Staging.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() + v := self.context.GetView() + v.FocusPoint(0, lo.Clamp(target, 0, v.ViewLinesHeight()-1), 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 (a non-sticky +// range that has actually been extended). +func (self *MainViewController) selectingRange() bool { + if self.sel().Mode != context.DiffSelectModeRange { + return false + } + start, end := self.context.GetView().SelectedLineRange() + return self.sel().RangeIsSticky || start != end +} + +// toggleSelectHunk switches between selecting the change block (hunk) around the +// cursor and a single line, mirroring the staging view's `a`. +func (self *MainViewController) toggleSelectHunk() error { + v := self.context.GetView() + if !v.Highlight { return nil } - - if showSelection { - showSelectionAtLine(v, target, true) + sel := self.sel() + if sel.Mode == context.DiffSelectModeHunk { + sel.Mode = context.DiffSelectModeLine + v.CancelRangeSelect() } else { - v.SetOrigin(0, target) + sel.Mode = context.DiffSelectModeHunk + sel.UserEnabledHunkMode = true + self.selectHunkAround(v.SelectedLineIdx()) } return nil } -func (self *MainViewController) nextChangeBlock() error { - return self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, true) -} - -func (self *MainViewController) prevChangeBlock() error { - return self.navigate(self.c.Helpers().Staging.AdjacentChangeBlock, false) -} - -func (self *MainViewController) nextFile() error { - return self.navigate(self.c.Helpers().Staging.AdjacentFile, true) -} - -func (self *MainViewController) prevFile() error { - return self.navigate(self.c.Helpers().Staging.AdjacentFile, false) -} - -func (self *MainViewController) handleLineChange(delta int) { +// toggleRangeSelect starts or cancels a sticky range selection (extended by plain +// up/down), mirroring the staging view's `v`. +func (self *MainViewController) toggleRangeSelect() error { v := self.context.GetView() - if v.Highlight { - lineIdxBefore := v.CursorY() + v.OriginY() - lineIdxAfter := lo.Clamp(lineIdxBefore+delta, 0, v.ViewLinesHeight()-1) - if delta == -1 { - checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) - } else if delta == 1 { - checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) - } - v.FocusPoint(0, lineIdxAfter, true) - } else { - if delta < 0 { - v.ScrollUp(-delta) - } else { - v.ScrollDown(delta) - self.c.ReadLinesToFillView(v) - } + if !v.Highlight { + return nil } + sel := self.sel() + 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. Mirrors the +// staging view's range-select keys. +func (self *MainViewController) extendRange(forward bool) error { + v := self.context.GetView() + if !v.Highlight { + return nil + } + sel := self.sel() + if !self.selectingRange() { + sel.Mode = context.DiffSelectModeRange + v.SetRangeSelectStart(v.SelectedLineIdx()) + } + sel.RangeIsSticky = false + delta := 1 + if !forward { + delta = -1 + } + self.moveCursor(delta) + return nil +} + +func (self *MainViewController) extendRangeUp() error { + return self.extendRange(false) +} + +func (self *MainViewController) extendRangeDown() error { + return self.extendRange(true) } 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() - if v.Highlight { - v.FocusPoint(0, 0, true) - } else { - self.handleLineChange(-v.ViewLinesHeight()) + if !v.Highlight { + self.scroll(-v.ViewLinesHeight()) + return nil } + self.selectAbsoluteLine(0) return nil } func (self *MainViewController) handleGotoBottom() error { - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadToEnd(func() { - self.c.OnUIThread(func() error { - v := self.context.GetView() - if v.Highlight { - v.FocusPoint(0, v.ViewLinesHeight()-1, true) - } else { - self.handleLineChange(v.ViewLinesHeight()) - } - return nil - }) - }) + manager := self.c.GetViewBufferManagerForView(self.context.GetView()) + if manager == nil { + return nil } - + manager.ReadToEnd(func() { + self.c.OnUIThread(func() error { + v := self.context.GetView() + if !v.Highlight { + self.scroll(v.ViewLinesHeight()) + return nil + } + self.selectAbsoluteLine(v.ViewLinesHeight() - 1) + return nil + }) + }) return nil } @@ -537,8 +736,9 @@ func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouse if !self.isDiffView() { return nil } - // A click points at a line, so it sets the selection there; a double-click - // additionally dives into staging/patch-building for that line. + // A click points at a line, so it sets a single-line selection there; a + // double-click additionally dives into staging/patch-building for that line. + resetDiffSelectMode(self.context) showSelectionAtLine(self.context.GetView(), opts.Y, false) if opts.IsDoubleClick { return self.enterForLine(opts.Y) @@ -555,6 +755,7 @@ func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.View if !self.isDiffView() { return nil } + resetDiffSelectMode(self.context) showSelectionAtLine(self.context.GetView(), opts.Y, false) if opts.IsDoubleClick { return self.enterForLine(opts.Y) 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 21c1a44c1..f72661c75 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -2,6 +2,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -77,10 +78,8 @@ func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { return self.focusMainView(self.c.Contexts().Normal, -1) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context, clickedLineIdx int) error { - if context, ok := mainViewContext.(types.ISearchableContext); ok { - context.ClearSearchString() - } +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *context.MainContext, clickedLineIdx int) error { + mainViewContext.ClearSearchString() self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) if !sidePanelShowsDiff(self.context) { @@ -89,11 +88,13 @@ func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext typ return nil } - view := mainViewContext.GetView() if clickedLineIdx >= 0 { - showSelectionAtLine(view, clickedLineIdx, false) + // A click points at a specific line, so select it directly (in the default + // single-line mode). + resetDiffSelectMode(mainViewContext) + showSelectionAtLine(mainViewContext.GetView(), clickedLineIdx, false) } else { - showInitialDiffSelection(self.c, view) + showInitialDiffSelection(self.c, mainViewContext) } return nil } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index ec8b7faf9..76d2201d2 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -98,9 +98,11 @@ type IBaseContext interface { // Likewise for the focused main view: we need this to communicate between a // side panel controller and the focused main view controller. AddOnClickFocusedMainViewFn(func(mainViewName string, clickedLineIdx int) error) - // And for staging the selected line directly from the focused main view (space), - // delegated to the side panel that owns the diff being shown. - AddOnStageFocusedMainViewFn(func(mainViewName string, viewLineIdx int) error) + // And for staging the selected line(s) directly from the focused main view + // (space), delegated to the side panel that owns the diff being shown. The + // inclusive view-line range is the current selection (a single line, a range, or + // a hunk). + AddOnStageFocusedMainViewFn(func(mainViewName string, firstLineIdx int, lastLineIdx int) error) // Adding on to the above, this is so that a list-specific handler can register // a hook for doing additional click handling AddOnClickFn(func(opts gocui.ViewMouseBindingOpts) error) @@ -333,9 +335,10 @@ type HasKeybindings interface { GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error // Implement this in a side-panel controller to stage/unstage (or, later, add to - // the custom patch) the selected diff line when the user presses space in the - // focused main view. Return nil to do nothing. - GetOnStageFocusedMainView() func(mainViewName string, viewLineIdx int) error + // the custom patch) the selected diff line(s) when the user presses space in the + // focused main view. The inclusive view-line range is the current selection (a + // single line, a range, or a hunk). Return nil to do nothing. + GetOnStageFocusedMainView() func(mainViewName string, firstLineIdx int, lastLineIdx int) error } type IController interface { diff --git a/pkg/integration/tests/staging/stage_hunk_from_main_view.go b/pkg/integration/tests/staging/stage_hunk_from_main_view.go new file mode 100644 index 000000000..e027675a7 --- /dev/null +++ b/pkg/integration/tests/staging/stage_hunk_from_main_view.go @@ -0,0 +1,55 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageHunkFromMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select a hunk in the focused main view and stage it, without diving into the staging 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\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + // Two separate change blocks, far enough apart to stay distinct hunks. + 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(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-three"), + ). + // `a` extends the selection to the whole change block around the cursor. + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction(). + Tap(func() { + t.Views().Secondary(). + ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + }). + // The other block is still unstaged. + ContainsLines( + Contains("-nine"), + Contains("+NINE"), + ) + }, +}) diff --git a/pkg/integration/tests/staging/stage_range_from_main_view.go b/pkg/integration/tests/staging/stage_range_from_main_view.go new file mode 100644 index 000000000..058afc9a8 --- /dev/null +++ b/pkg/integration/tests/staging/stage_range_from_main_view.go @@ -0,0 +1,66 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageRangeFromMainView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select a range of diff lines in the focused main view and stage it, without diving into the staging 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\n") + shell.Commit("one") + + shell.UpdateFile("file1", "ONE\ntwo\nthree\nfour\nfive\nsix\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + // Focusing the main view selects the first change line (a single line). + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-one"), + ). + // Select a range with `v` and extend it down past the modified line and + // some context, then stage it. + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+four")). + SelectedLines( + Contains("-one"), + Contains("+ONE"), + Contains(" two"), + Contains(" three"), + Contains("+four"), + ). + PressPrimaryAction(). + Tap(func() { + // The selected change lines — the deletion, the addition replacing it, + // and the added 'four' — are now staged; the context lines came along + // but aren't themselves staged. + t.Views().Secondary(). + ContainsLines( + Contains("-one"), + Contains("+ONE"), + Contains(" two"), + Contains(" three"), + Contains("+four"), + ) + }). + // The unstaged half holds only the additions we didn't select. + ContainsLines( + Contains("+five"), + Contains("+six"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 15ef6f8c7..4dcfd9a6d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -412,12 +412,14 @@ var tests = []*components.IntegrationTest{ staging.Search, staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, + staging.StageHunkFromMainView, staging.StageHunks, staging.StageHunksWithRapidKeypresses, staging.StageLines, staging.StagePartialBlockOfChangesFirstLines, staging.StagePartialBlockOfChangesLastLines, staging.StagePartialBlockOfChangesMiddleLines, + staging.StageRangeFromMainView, staging.StageRanges, stash.Apply, stash.ApplyPatch,