From e887a2eb3c0ece1d2be78b869ff936a0703a3940 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Thu, 11 Jan 2024 09:39:20 +1100 Subject: [PATCH 01/11] Stop hiding debug vscode launch tasks No idea why these were hidden in the first place --- .vscode/launch.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 4e05edffb..be436b55f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,9 +13,6 @@ ], "hideSystemGoroutines": true, "console": "integratedTerminal", - "presentation": { - "hidden": true - } }, { "name": "Tail Lazygit logs", @@ -28,9 +25,6 @@ "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml" ], "console": "integratedTerminal", - "presentation": { - "hidden": true - } }, { "name": "Attach to a running Lazygit", From 24a4302c528e3a11b30855c006669de1adf9e1d4 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 7 Jan 2024 19:44:19 +1100 Subject: [PATCH 02/11] Add range selection ability on list contexts This adds range select ability in two ways: 1) Sticky: like what we already have with the staging view i.e. press v then use arrow keys 2) Non-sticky: where you just use shift+up/down to expand the range The state machine works like this: (no range, press 'v') -> sticky range (no range, press arrow) -> no range (no range, press shift+arrow) -> nonsticky range (sticky range, press 'v') -> no range (sticky range, press arrow) -> sticky range (sticky range, press shift+arrow) -> nonsticky range (nonsticky range, press 'v') -> no range (nonsticky range, press arrow) -> no range (nonsticky range, press shift+arrow) -> nonsticky range --- docs/Config.md | 5 +- docs/README.md | 3 +- docs/Range_Select.md | 14 ++ go.mod | 6 +- go.sum | 10 +- pkg/config/user_config.go | 22 +-- pkg/gui/context/list_context_trait.go | 10 +- pkg/gui/context/traits/list_cursor.go | 103 ++++++++++-- pkg/gui/context/view_trait.go | 8 + pkg/gui/controllers/list_controller.go | 46 +++++- .../controllers/patch_explorer_controller.go | 9 +- pkg/gui/filetree/file_tree_view_model.go | 2 +- pkg/gui/keybindings/keybindings.go | 122 +++++++------- pkg/gui/types/context.go | 10 +- pkg/i18n/chinese.go | 2 +- pkg/i18n/dutch.go | 2 +- pkg/i18n/english.go | 8 +- pkg/i18n/japanese.go | 2 +- pkg/i18n/korean.go | 2 +- pkg/i18n/russian.go | 2 +- pkg/i18n/traditional_chinese.go | 2 +- .../tests/commit/stage_range_of_lines.go | 2 +- pkg/integration/tests/demo/stage_lines.go | 2 +- .../patch_building/specific_selection.go | 2 +- pkg/integration/tests/staging/stage_ranges.go | 6 +- pkg/utils/utils.go | 7 + schema/config.json | 10 +- .../jesseduffield/gocui/keybinding.go | 48 +++--- .../jesseduffield/gocui/tcell_driver.go | 8 + vendor/github.com/jesseduffield/gocui/view.go | 150 ++++++++++++++---- vendor/golang.org/x/sys/unix/mkerrors.sh | 37 +++-- vendor/golang.org/x/sys/unix/zerrors_linux.go | 54 +++++++ .../x/sys/unix/zsyscall_openbsd_386.go | 2 - .../x/sys/unix/zsyscall_openbsd_amd64.go | 2 - .../x/sys/unix/zsyscall_openbsd_arm.go | 2 - .../x/sys/unix/zsyscall_openbsd_arm64.go | 2 - .../x/sys/unix/zsyscall_openbsd_mips64.go | 2 - .../x/sys/unix/zsyscall_openbsd_ppc64.go | 2 - .../x/sys/unix/zsyscall_openbsd_riscv64.go | 2 - .../x/sys/windows/syscall_windows.go | 1 + .../x/sys/windows/zsyscall_windows.go | 9 ++ vendor/modules.txt | 6 +- 42 files changed, 533 insertions(+), 213 deletions(-) create mode 100644 docs/Range_Select.md diff --git a/docs/Config.md b/docs/Config.md index 8b37c6aaa..8b8b62c11 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -201,6 +201,9 @@ keybinding: toggleWhitespaceInDiffView: '' increaseContextInDiffView: '}' decreaseContextInDiffView: '{' + toggleRangeSelect: 'v' + rangeSelectUp: '' + rangeSelectDown: '' status: checkForUpdate: 'u' recentRepos: '' @@ -263,8 +266,6 @@ keybinding: commitFiles: checkoutCommitFile: 'c' main: - toggleDragSelect: 'v' - toggleDragSelect-alt: 'V' toggleSelectHunk: 'a' pickBothHunks: 'b' submodules: diff --git a/docs/README.md b/docs/README.md index 604c8a07a..d840637a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,8 +3,9 @@ * [Configuration](./Config.md). * [Custom Commands](./Custom_Command_Keybindings.md) * [Custom Pagers](./Custom_Pagers.md) +* [Dev docs](./dev) * [Keybindings](./keybindings) * [Undo/Redo](./Undoing.md) +* [Range Select](./Range_Select.md) * [Searching/Filtering](./Searching.md) * [Stacked Branches](./Stacked_Branches.md) -* [Dev docs](./dev) diff --git a/docs/Range_Select.md b/docs/Range_Select.md new file mode 100644 index 000000000..e46c26897 --- /dev/null +++ b/docs/Range_Select.md @@ -0,0 +1,14 @@ +# Range Select + +Some actions can be performed on a range of contiguous items. For example: +* staging multiple files at once +* squashing multiple commits at once +* copying (for cherry-pick) multiple commits at once + +There are two ways to select a range of items: +1. Sticky range select: Press 'v' to toggle range select, then expand the selection using the up/down arrow key. To reset the selection, press 'v' again. +2. Non-sticky range select: Press shift+up or shift+down to expand the selection. To reset the selection, press up/down without shift. + +The sticky option will be more familiar to vim users, and the second option will feel more natural to users who aren't used to doing things in a modal way. + +In order to perform an action on a range of items, simply press the normal key for that action. If the action only works on individual items, it will raise an error. This is a new feature and the plan is to incrementally support range select for more and more actions. If there is an action you would like to support range select which currently does not, please raise an issue in the repo. diff --git a/go.mod b/go.mod index 0d709c370..182b42088 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d - github.com/jesseduffield/gocui v0.3.1-0.20240103192639-2874168c14db + github.com/jesseduffield/gocui v0.3.1-0.20240118234343-2d41754af383 github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e @@ -74,8 +74,8 @@ require ( github.com/xanzy/ssh-agent v0.2.1 // indirect golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect golang.org/x/net v0.7.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 1762cf605..46d48d48e 100644 --- a/go.sum +++ b/go.sum @@ -187,8 +187,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk= github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE= github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o= -github.com/jesseduffield/gocui v0.3.1-0.20240103192639-2874168c14db h1:ihJdYk85/XQLGiG3b6m8P2z+RUohRMtPmX74YR9IT8s= -github.com/jesseduffield/gocui v0.3.1-0.20240103192639-2874168c14db/go.mod h1:9zkyjnUmdL3+sUknJrQy/3HweUu8mVln/3J2wRF/l8M= +github.com/jesseduffield/gocui v0.3.1-0.20240118234343-2d41754af383 h1:twcgVo+K7UTXwrsNtlCvTi8AyCp7CuBX//+j4wWkivQ= +github.com/jesseduffield/gocui v0.3.1-0.20240118234343-2d41754af383/go.mod h1:9zkyjnUmdL3+sUknJrQy/3HweUu8mVln/3J2wRF/l8M= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= @@ -469,13 +469,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 602dc54bf..d7bea4f03 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -304,6 +304,9 @@ type KeybindingUniversalConfig struct { ScrollRight string `yaml:"scrollRight"` GotoTop string `yaml:"gotoTop"` GotoBottom string `yaml:"gotoBottom"` + ToggleRangeSelect string `yaml:"toggleRangeSelect"` + RangeSelectDown string `yaml:"rangeSelectDown"` + RangeSelectUp string `yaml:"rangeSelectUp"` PrevBlock string `yaml:"prevBlock"` NextBlock string `yaml:"nextBlock"` PrevBlockAlt string `yaml:"prevBlock-alt"` @@ -441,11 +444,9 @@ type KeybindingCommitFilesConfig struct { } type KeybindingMainConfig struct { - ToggleDragSelect string `yaml:"toggleDragSelect"` - ToggleDragSelectAlt string `yaml:"toggleDragSelect-alt"` - ToggleSelectHunk string `yaml:"toggleSelectHunk"` - PickBothHunks string `yaml:"pickBothHunks"` - EditSelectHunk string `yaml:"editSelectHunk"` + ToggleSelectHunk string `yaml:"toggleSelectHunk"` + PickBothHunks string `yaml:"pickBothHunks"` + EditSelectHunk string `yaml:"editSelectHunk"` } type KeybindingSubmodulesConfig struct { @@ -704,6 +705,9 @@ func GetDefaultConfig() *UserConfig { ScrollRight: "L", GotoTop: "<", GotoBottom: ">", + ToggleRangeSelect: "v", + RangeSelectDown: "", + RangeSelectUp: "", PrevBlock: "", NextBlock: "", PrevBlockAlt: "h", @@ -833,11 +837,9 @@ func GetDefaultConfig() *UserConfig { CheckoutCommitFile: "c", }, Main: KeybindingMainConfig{ - ToggleDragSelect: "v", - ToggleDragSelectAlt: "V", - ToggleSelectHunk: "a", - PickBothHunks: "b", - EditSelectHunk: "E", + ToggleSelectHunk: "a", + PickBothHunks: "b", + EditSelectHunk: "E", }, Submodules: KeybindingSubmodulesConfig{ Init: "i", diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index ca3b3254f..c4f0e2549 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -32,6 +32,14 @@ func (self *ListContextTrait) FocusLine() { self.GetViewTrait().FocusPoint( self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx())) + selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx() + if isSelectingRange { + selectRangeIndex = self.ModelIndexToViewIndex(selectRangeIndex) + self.GetViewTrait().SetRangeSelectStart(selectRangeIndex) + } else { + self.GetViewTrait().CancelRangeSelect() + } + // If FocusPoint() caused the view to scroll (because the selected line // was out of view before), we need to rerender the view port again. // This can happen when pressing , or . to scroll by pages, or < or > to @@ -84,7 +92,7 @@ func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) error // OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view func (self *ListContextTrait) HandleRender() error { - self.list.RefreshSelectedIdx() + self.list.ClampSelection() content := self.renderLines(-1, -1) self.GetViewTrait().SetContent(content) self.c.Render() diff --git a/pkg/gui/context/traits/list_cursor.go b/pkg/gui/context/traits/list_cursor.go index 9e86d5139..647f2a36b 100644 --- a/pkg/gui/context/traits/list_cursor.go +++ b/pkg/gui/context/traits/list_cursor.go @@ -9,13 +9,34 @@ type HasLength interface { Len() int } +type RangeSelectMode int + +const ( + // None means we are not selecting a range + RangeSelectModeNone RangeSelectMode = iota + // Sticky range select is started by pressing 'v', then the range is expanded + // when you move up or down. It is cancelled by pressing 'v' again. + RangeSelectModeSticky + // Nonsticky range select is started by pressing shift+arrow and cancelled + // when pressing up/down without shift, or by pressing 'v' + RangeSelectModeNonSticky +) + type ListCursor struct { - selectedIdx int - list HasLength + selectedIdx int + rangeSelectMode RangeSelectMode + // value is ignored when rangeSelectMode is RangeSelectModeNone + rangeStartIdx int + list HasLength } func NewListCursor(list HasLength) *ListCursor { - return &ListCursor{selectedIdx: 0, list: list} + return &ListCursor{ + selectedIdx: 0, + rangeStartIdx: 0, + rangeSelectMode: RangeSelectModeNone, + list: list, + } } var _ types.IListCursor = (*ListCursor)(nil) @@ -25,24 +46,86 @@ func (self *ListCursor) GetSelectedLineIdx() int { } func (self *ListCursor) SetSelectedLineIdx(value int) { + self.selectedIdx = self.clampValue(value) +} + +func (self *ListCursor) clampValue(value int) int { clampedValue := -1 if self.list.Len() > 0 { clampedValue = utils.Clamp(value, 0, self.list.Len()-1) } - self.selectedIdx = clampedValue + return clampedValue } -// moves the cursor up or down by the given amount -func (self *ListCursor) MoveSelectedLine(delta int) { - self.SetSelectedLineIdx(self.selectedIdx + delta) +// Moves the cursor up or down by the given amount. +// If we are in non-sticky range select mode, this will cancel the range select +func (self *ListCursor) MoveSelectedLine(change int) { + if self.rangeSelectMode == RangeSelectModeNonSticky { + self.CancelRangeSelect() + } + + self.SetSelectedLineIdx(self.selectedIdx + change) } -// to be called when the model might have shrunk so that our selection is not not out of bounds -func (self *ListCursor) RefreshSelectedIdx() { - self.SetSelectedLineIdx(self.selectedIdx) +// Moves the cursor up or down by the given amount, and also moves the range start +// index by the same amount +func (self *ListCursor) MoveSelection(delta int) { + self.selectedIdx = self.clampValue(self.selectedIdx + delta) + if self.IsSelectingRange() { + self.rangeStartIdx = self.clampValue(self.rangeStartIdx + delta) + } +} + +// To be called when the model might have shrunk so that our selection is not out of bounds +func (self *ListCursor) ClampSelection() { + self.selectedIdx = self.clampValue(self.selectedIdx) + self.rangeStartIdx = self.clampValue(self.rangeStartIdx) } func (self *ListCursor) Len() int { return self.list.Len() } + +func (self *ListCursor) GetRangeStartIdx() (int, bool) { + if self.IsSelectingRange() { + return self.rangeStartIdx, true + } + + return 0, false +} + +func (self *ListCursor) CancelRangeSelect() { + self.rangeSelectMode = RangeSelectModeNone +} + +func (self *ListCursor) IsSelectingRange() bool { + return self.rangeSelectMode != RangeSelectModeNone +} + +func (self *ListCursor) GetSelectionRange() (int, int) { + if self.IsSelectingRange() { + return utils.MinMax(self.selectedIdx, self.rangeStartIdx) + } + + return self.selectedIdx, self.selectedIdx +} + +func (self *ListCursor) ToggleStickyRange() { + if self.IsSelectingRange() { + self.CancelRangeSelect() + } else { + self.rangeStartIdx = self.selectedIdx + self.rangeSelectMode = RangeSelectModeSticky + } +} + +func (self *ListCursor) ExpandNonStickyRange(change int) { + if !self.IsSelectingRange() { + self.rangeStartIdx = self.selectedIdx + } + + self.rangeSelectMode = RangeSelectModeNonSticky + + self.SetSelectedLineIdx(self.selectedIdx + change) +} diff --git a/pkg/gui/context/view_trait.go b/pkg/gui/context/view_trait.go index bf8a49e43..1179a8b14 100644 --- a/pkg/gui/context/view_trait.go +++ b/pkg/gui/context/view_trait.go @@ -21,6 +21,14 @@ func (self *ViewTrait) FocusPoint(yIdx int) { self.view.FocusPoint(self.view.OriginX(), yIdx) } +func (self *ViewTrait) SetRangeSelectStart(yIdx int) { + self.view.SetRangeSelectStart(yIdx) +} + +func (self *ViewTrait) CancelRangeSelect() { + self.view.CancelRangeSelect() +} + func (self *ViewTrait) SetViewPortContent(content string) { _, y := self.view.Origin() self.view.OverwriteLines(y, content) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 025561993..1f3c743bc 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -71,9 +71,25 @@ func (self *ListController) scrollHorizontal(scrollFunc func()) error { } func (self *ListController) handleLineChange(change int) error { - before := self.context.GetList().GetSelectedLineIdx() - self.context.GetList().MoveSelectedLine(change) - after := self.context.GetList().GetSelectedLineIdx() + return self.handleLineChangeAux( + self.context.GetList().MoveSelectedLine, change, + ) +} + +func (self *ListController) HandleRangeSelectChange(change int) error { + return self.handleLineChangeAux( + self.context.GetList().ExpandNonStickyRange, change, + ) +} + +func (self *ListController) handleLineChangeAux(f func(int), change int) error { + list := self.context.GetList() + + rangeBefore := list.IsSelectingRange() + before := list.GetSelectedLineIdx() + f(change) + rangeAfter := list.IsSelectingRange() + after := list.GetSelectedLineIdx() if err := self.pushContextIfNotFocused(); err != nil { return err @@ -81,7 +97,8 @@ func (self *ListController) handleLineChange(change int) error { // doing this check so that if we're holding the up key at the start of the list // we're not constantly re-rendering the main view. - if before != after { + cursorMoved := before != after + if cursorMoved { if change == -1 { checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig, self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after)) @@ -89,7 +106,9 @@ func (self *ListController) handleLineChange(change int) error { checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig, self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after)) } + } + if cursorMoved || rangeBefore != rangeAfter { return self.context.HandleFocus(types.OnFocusOpts{}) } @@ -112,6 +131,22 @@ func (self *ListController) HandleGotoBottom() error { return self.handleLineChange(self.context.GetList().Len()) } +func (self *ListController) HandleToggleRangeSelect() error { + list := self.context.GetList() + + list.ToggleStickyRange() + + return self.context.HandleFocus(types.OnFocusOpts{}) +} + +func (self *ListController) HandleRangeSelectDown() error { + return self.HandleRangeSelectChange(1) +} + +func (self *ListController) HandleRangeSelectUp() error { + return self.HandleRangeSelectChange(-1) +} + func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx() newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y) @@ -159,6 +194,9 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types. {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom}, + {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect}, + {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown}, + {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp}, } } diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 6f193cf2d..2083c2943 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -75,14 +75,9 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) Handler: self.withRenderAndFocus(self.HandleNextHunk), }, { - Key: opts.GetKey(opts.Config.Main.ToggleDragSelect), + Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), - Description: self.c.Tr.ToggleDragSelect, - }, - { - Key: opts.GetKey(opts.Config.Main.ToggleDragSelectAlt), - Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), - Description: self.c.Tr.ToggleDragSelect, + Description: self.c.Tr.ToggleRangeSelect, }, { Key: opts.GetKey(opts.Config.Main.ToggleSelectHunk), diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 547b62b91..f19f74fbc 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -85,7 +85,7 @@ func (self *FileTreeViewModel) SetTree() { } } - self.RefreshSelectedIdx() + self.ClampSelection() } // Let's try to find our file again and move the cursor to that. diff --git a/pkg/gui/keybindings/keybindings.go b/pkg/gui/keybindings/keybindings.go index fd8c694cf..da917b82e 100644 --- a/pkg/gui/keybindings/keybindings.go +++ b/pkg/gui/keybindings/keybindings.go @@ -13,66 +13,68 @@ import ( ) var labelByKey = map[gocui.Key]string{ - gocui.KeyF1: "", - gocui.KeyF2: "", - gocui.KeyF3: "", - gocui.KeyF4: "", - gocui.KeyF5: "", - gocui.KeyF6: "", - gocui.KeyF7: "", - gocui.KeyF8: "", - gocui.KeyF9: "", - gocui.KeyF10: "", - gocui.KeyF11: "", - gocui.KeyF12: "", - gocui.KeyInsert: "", - gocui.KeyDelete: "", - gocui.KeyHome: "", - gocui.KeyEnd: "", - gocui.KeyPgup: "", - gocui.KeyPgdn: "", - gocui.KeyArrowUp: "", - gocui.KeyArrowDown: "", - gocui.KeyArrowLeft: "", - gocui.KeyArrowRight: "", - gocui.KeyTab: "", // - gocui.KeyBacktab: "", - gocui.KeyEnter: "", // - gocui.KeyAltEnter: "", - gocui.KeyEsc: "", // , - gocui.KeyBackspace: "", // - gocui.KeyCtrlSpace: "", // , - gocui.KeyCtrlSlash: "", // - gocui.KeySpace: "", - gocui.KeyCtrlA: "", - gocui.KeyCtrlB: "", - gocui.KeyCtrlC: "", - gocui.KeyCtrlD: "", - gocui.KeyCtrlE: "", - gocui.KeyCtrlF: "", - gocui.KeyCtrlG: "", - gocui.KeyCtrlJ: "", - gocui.KeyCtrlK: "", - gocui.KeyCtrlL: "", - gocui.KeyCtrlN: "", - gocui.KeyCtrlO: "", - gocui.KeyCtrlP: "", - gocui.KeyCtrlQ: "", - gocui.KeyCtrlR: "", - gocui.KeyCtrlS: "", - gocui.KeyCtrlT: "", - gocui.KeyCtrlU: "", - gocui.KeyCtrlV: "", - gocui.KeyCtrlW: "", - gocui.KeyCtrlX: "", - gocui.KeyCtrlY: "", - gocui.KeyCtrlZ: "", - gocui.KeyCtrl4: "", // - gocui.KeyCtrl5: "", // - gocui.KeyCtrl6: "", - gocui.KeyCtrl8: "", - gocui.MouseWheelUp: "mouse wheel up", - gocui.MouseWheelDown: "mouse wheel down", + gocui.KeyF1: "", + gocui.KeyF2: "", + gocui.KeyF3: "", + gocui.KeyF4: "", + gocui.KeyF5: "", + gocui.KeyF6: "", + gocui.KeyF7: "", + gocui.KeyF8: "", + gocui.KeyF9: "", + gocui.KeyF10: "", + gocui.KeyF11: "", + gocui.KeyF12: "", + gocui.KeyInsert: "", + gocui.KeyDelete: "", + gocui.KeyHome: "", + gocui.KeyEnd: "", + gocui.KeyPgup: "", + gocui.KeyPgdn: "", + gocui.KeyArrowUp: "", + gocui.KeyShiftArrowUp: "", + gocui.KeyArrowDown: "", + gocui.KeyShiftArrowDown: "", + gocui.KeyArrowLeft: "", + gocui.KeyArrowRight: "", + gocui.KeyTab: "", // + gocui.KeyBacktab: "", + gocui.KeyEnter: "", // + gocui.KeyAltEnter: "", + gocui.KeyEsc: "", // , + gocui.KeyBackspace: "", // + gocui.KeyCtrlSpace: "", // , + gocui.KeyCtrlSlash: "", // + gocui.KeySpace: "", + gocui.KeyCtrlA: "", + gocui.KeyCtrlB: "", + gocui.KeyCtrlC: "", + gocui.KeyCtrlD: "", + gocui.KeyCtrlE: "", + gocui.KeyCtrlF: "", + gocui.KeyCtrlG: "", + gocui.KeyCtrlJ: "", + gocui.KeyCtrlK: "", + gocui.KeyCtrlL: "", + gocui.KeyCtrlN: "", + gocui.KeyCtrlO: "", + gocui.KeyCtrlP: "", + gocui.KeyCtrlQ: "", + gocui.KeyCtrlR: "", + gocui.KeyCtrlS: "", + gocui.KeyCtrlT: "", + gocui.KeyCtrlU: "", + gocui.KeyCtrlV: "", + gocui.KeyCtrlW: "", + gocui.KeyCtrlX: "", + gocui.KeyCtrlY: "", + gocui.KeyCtrlZ: "", + gocui.KeyCtrl4: "", // + gocui.KeyCtrl5: "", // + gocui.KeyCtrl6: "", + gocui.KeyCtrl8: "", + gocui.MouseWheelUp: "mouse wheel up", + gocui.MouseWheelDown: "mouse wheel down", } var keyByLabel = lo.Invert(labelByKey) diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 82f03c70f..b20d70beb 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -163,6 +163,8 @@ type IPatchExplorerContext interface { type IViewTrait interface { FocusPoint(yIdx int) + SetRangeSelectStart(yIdx int) + CancelRangeSelect() SetViewPortContent(content string) SetContent(content string) SetFooter(value string) @@ -223,7 +225,13 @@ type IListCursor interface { GetSelectedLineIdx() int SetSelectedLineIdx(value int) MoveSelectedLine(delta int) - RefreshSelectedIdx() + ClampSelection() + CancelRangeSelect() + GetRangeStartIdx() (int, bool) + GetSelectionRange() (int, int) + IsSelectingRange() bool + ToggleStickyRange() + ExpandNonStickyRange(int) } type IListPanelState interface { diff --git a/pkg/i18n/chinese.go b/pkg/i18n/chinese.go index 234d470db..0482f4046 100644 --- a/pkg/i18n/chinese.go +++ b/pkg/i18n/chinese.go @@ -163,7 +163,7 @@ func chineseTranslationSet() TranslationSet { FileStagingRequirements: `只能暂存跟踪文件的单独行`, StageSelection: `切换行暂存状态`, DiscardSelection: `取消变更 (git reset)`, - ToggleDragSelect: `切换拖动选择`, + ToggleRangeSelect: `切换拖动选择`, ToggleSelectHunk: `切换选择区块`, ToggleSelectionForPatch: `添加/移除 行到补丁`, ToggleStagingPanel: `切换到其他面板`, diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go index 7abfaa437..79207490b 100644 --- a/pkg/i18n/dutch.go +++ b/pkg/i18n/dutch.go @@ -128,7 +128,7 @@ func dutchTranslationSet() TranslationSet { FileStagingRequirements: `Kan alleen individuele lijnen stagen van getrackte bestanden met onstaged veranderingen`, StageSelection: `Toggle lijnen staged / unstaged`, DiscardSelection: `Verwijdert change (git reset)`, - ToggleDragSelect: `Toggle drag selecteer`, + ToggleRangeSelect: `Toggle drag selecteer`, ToggleSelectHunk: `Toggle selecteer hunk`, ToggleSelectionForPatch: `Voeg toe/verwijder lijn(en) in patch`, ToggleStagingPanel: `Ga naar een ander paneel`, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8886217c3..ad859dc1d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -200,7 +200,6 @@ type TranslationSet struct { FileStagingRequirements string StageSelection string DiscardSelection string - ToggleDragSelect string ToggleSelectHunk string ToggleSelectionForPatch string EditHunk string @@ -651,6 +650,9 @@ type TranslationSet struct { QuickStartInteractiveRebase string QuickStartInteractiveRebaseTooltip string CannotQuickStartInteractiveRebase string + ToggleRangeSelect string + RangeSelectUp string + RangeSelectDown string Actions Actions Bisect Bisect Log Log @@ -1033,7 +1035,7 @@ func EnglishTranslationSet() TranslationSet { FileStagingRequirements: `Can only stage individual lines for tracked files`, StageSelection: `Toggle line staged / unstaged`, DiscardSelection: `Discard change (git reset)`, - ToggleDragSelect: `Toggle drag select`, + ToggleRangeSelect: `Toggle range select`, ToggleSelectHunk: `Toggle select hunk`, ToggleSelectionForPatch: `Add/Remove line(s) to patch`, EditHunk: `Edit hunk`, @@ -1483,6 +1485,8 @@ func EnglishTranslationSet() TranslationSet { QuickStartInteractiveRebase: "Start interactive rebase", QuickStartInteractiveRebaseTooltip: "Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.\nIf you would instead like to start an interactive rebase from the selected commit, press `{{.editKey}}`.", CannotQuickStartInteractiveRebase: "Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `{{.editKey}}`.", + RangeSelectUp: "Range select up", + RangeSelectDown: "Range select down", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) CheckoutCommit: "Checkout commit", diff --git a/pkg/i18n/japanese.go b/pkg/i18n/japanese.go index 089c95ed6..660746384 100644 --- a/pkg/i18n/japanese.go +++ b/pkg/i18n/japanese.go @@ -162,7 +162,7 @@ func japaneseTranslationSet() TranslationSet { // FileStagingRequirements: `Can only stage individual lines for tracked files`, StageSelection: `選択行をステージ/アンステージ`, DiscardSelection: `変更を削除 (git reset)`, - ToggleDragSelect: `範囲選択を切り替え`, + ToggleRangeSelect: `範囲選択を切り替え`, ToggleSelectHunk: `Hunk選択を切り替え`, ToggleSelectionForPatch: `行をパッチに追加/削除`, ToggleStagingPanel: `パネルを切り替え`, diff --git a/pkg/i18n/korean.go b/pkg/i18n/korean.go index d6b7793a0..ad23090e7 100644 --- a/pkg/i18n/korean.go +++ b/pkg/i18n/korean.go @@ -164,7 +164,7 @@ func koreanTranslationSet() TranslationSet { FileStagingRequirements: `추적된 파일에 대해 개별 라인만 stage할 수 있습니다.`, StageSelection: `선택한 행을 staged / unstaged`, DiscardSelection: `변경을 삭제 (git reset)`, - ToggleDragSelect: `드래그 선택 전환`, + ToggleRangeSelect: `드래그 선택 전환`, ToggleSelectHunk: `Toggle select hunk`, ToggleSelectionForPatch: `Line(s)을 패치에 추가/삭제`, ToggleStagingPanel: `패널 전환`, diff --git a/pkg/i18n/russian.go b/pkg/i18n/russian.go index 0e63841b9..387e34974 100644 --- a/pkg/i18n/russian.go +++ b/pkg/i18n/russian.go @@ -194,7 +194,7 @@ func RussianTranslationSet() TranslationSet { FileStagingRequirements: `Можно проиндексировать только отдельные строки для отслеживаемых файлов`, StageSelection: `Переключить строку в проиндексированные / непроиндексированные`, DiscardSelection: `Отменить изменение (git reset)`, - ToggleDragSelect: `Переключить выборку перетаскивания`, + ToggleRangeSelect: `Переключить выборку перетаскивания`, ToggleSelectHunk: `Переключить выборку частей`, ToggleSelectionForPatch: `Добавить/удалить строку(и) для патча`, EditHunk: `Изменить эту часть`, diff --git a/pkg/i18n/traditional_chinese.go b/pkg/i18n/traditional_chinese.go index 703c6667c..12207edcf 100644 --- a/pkg/i18n/traditional_chinese.go +++ b/pkg/i18n/traditional_chinese.go @@ -227,7 +227,7 @@ func traditionalChineseTranslationSet() TranslationSet { FileStagingRequirements: `只能選擇跟踪檔案中的單個行`, StageSelection: `切換現有行的狀態 (已預存/未預存)`, DiscardSelection: `刪除變更 (git reset)`, - ToggleDragSelect: `切換拖曳選擇`, + ToggleRangeSelect: `切換拖曳選擇`, ToggleSelectHunk: `切換選擇程式碼塊`, ToggleSelectionForPatch: `向 (或從) 補丁中添加/刪除行`, EditHunk: `編輯程式碼塊`, diff --git a/pkg/integration/tests/commit/stage_range_of_lines.go b/pkg/integration/tests/commit/stage_range_of_lines.go index 1beef60a2..c43706de9 100644 --- a/pkg/integration/tests/commit/stage_range_of_lines.go +++ b/pkg/integration/tests/commit/stage_range_of_lines.go @@ -25,7 +25,7 @@ var StageRangeOfLines = NewIntegrationTest(NewIntegrationTestArgs{ Contains("-1st\n-2nd\n+1st changed\n+2nd changed\n 3rd\n 4th\n-5th\n+5th changed\n 6th"), ). SelectedLine(Equals("-1st")). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). SelectNextItem(). SelectNextItem(). diff --git a/pkg/integration/tests/demo/stage_lines.go b/pkg/integration/tests/demo/stage_lines.go index 4e8db4fd4..4614db29e 100644 --- a/pkg/integration/tests/demo/stage_lines.go +++ b/pkg/integration/tests/demo/stage_lines.go @@ -60,7 +60,7 @@ var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Staging(). IsFocused(). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). PressFast(keys.Universal.NextItem). PressFast(keys.Universal.NextItem). Wait(500). diff --git a/pkg/integration/tests/patch_building/specific_selection.go b/pkg/integration/tests/patch_building/specific_selection.go index cfb7e64f1..b9c9da8a6 100644 --- a/pkg/integration/tests/patch_building/specific_selection.go +++ b/pkg/integration/tests/patch_building/specific_selection.go @@ -107,7 +107,7 @@ var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{ ). PressPrimaryAction(). NavigateToLine(Contains("+2c")). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+2e")). PressPrimaryAction(). NavigateToLine(Contains("+2g")). diff --git a/pkg/integration/tests/staging/stage_ranges.go b/pkg/integration/tests/staging/stage_ranges.go index db5d7148f..cd2157e0c 100644 --- a/pkg/integration/tests/staging/stage_ranges.go +++ b/pkg/integration/tests/staging/stage_ranges.go @@ -29,7 +29,7 @@ var StageRanges = NewIntegrationTest(NewIntegrationTestArgs{ SelectedLines( Contains("+three"), ). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+five")). SelectedLines( Contains("+three"), @@ -60,7 +60,7 @@ var StageRanges = NewIntegrationTest(NewIntegrationTestArgs{ SelectedLines( Contains("+three"), ). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("+five")). SelectedLines( Contains("+three"), @@ -88,7 +88,7 @@ var StageRanges = NewIntegrationTest(NewIntegrationTestArgs{ SelectedLines( Contains("+four"), ). - Press(keys.Main.ToggleDragSelect). + Press(keys.Universal.ToggleRangeSelect). SelectNextItem(). SelectedLines( Contains("+four"), diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index f40cd4fd3..ae876568f 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -50,6 +50,13 @@ func Max(x, y int) int { return y } +func MinMax(x int, y int) (int, int) { + if x < y { + return x, y + } + return y, x +} + func Clamp(x int, min int, max int) int { if x < min { return min diff --git a/schema/config.json b/schema/config.json index 3de7df17d..1239872ee 100644 --- a/schema/config.json +++ b/schema/config.json @@ -1178,14 +1178,6 @@ }, "main": { "properties": { - "toggleDragSelect": { - "type": "string", - "default": "v" - }, - "toggleDragSelect-alt": { - "type": "string", - "default": "V" - }, "toggleSelectHunk": { "type": "string", "default": "a" @@ -1523,4 +1515,4 @@ }, "additionalProperties": false, "type": "object" -} \ No newline at end of file +} diff --git a/vendor/github.com/jesseduffield/gocui/keybinding.go b/vendor/github.com/jesseduffield/gocui/keybinding.go index bee180aea..e2b931d7e 100644 --- a/vendor/github.com/jesseduffield/gocui/keybinding.go +++ b/vendor/github.com/jesseduffield/gocui/keybinding.go @@ -143,7 +143,9 @@ var translate = map[string]Key{ "Pgup": KeyPgup, "Pgdn": KeyPgdn, "ArrowUp": KeyArrowUp, + "ShiftArrowUp": KeyShiftArrowUp, "ArrowDown": KeyArrowDown, + "ShiftArrowDown": KeyShiftArrowDown, "ArrowLeft": KeyArrowLeft, "ArrowRight": KeyArrowRight, "CtrlTilde": KeyCtrlTilde, @@ -203,28 +205,30 @@ var translate = map[string]Key{ // Special keys. const ( - KeyF1 Key = Key(tcell.KeyF1) - KeyF2 = Key(tcell.KeyF2) - KeyF3 = Key(tcell.KeyF3) - KeyF4 = Key(tcell.KeyF4) - KeyF5 = Key(tcell.KeyF5) - KeyF6 = Key(tcell.KeyF6) - KeyF7 = Key(tcell.KeyF7) - KeyF8 = Key(tcell.KeyF8) - KeyF9 = Key(tcell.KeyF9) - KeyF10 = Key(tcell.KeyF10) - KeyF11 = Key(tcell.KeyF11) - KeyF12 = Key(tcell.KeyF12) - KeyInsert = Key(tcell.KeyInsert) - KeyDelete = Key(tcell.KeyDelete) - KeyHome = Key(tcell.KeyHome) - KeyEnd = Key(tcell.KeyEnd) - KeyPgdn = Key(tcell.KeyPgDn) - KeyPgup = Key(tcell.KeyPgUp) - KeyArrowUp = Key(tcell.KeyUp) - KeyArrowDown = Key(tcell.KeyDown) - KeyArrowLeft = Key(tcell.KeyLeft) - KeyArrowRight = Key(tcell.KeyRight) + KeyF1 Key = Key(tcell.KeyF1) + KeyF2 = Key(tcell.KeyF2) + KeyF3 = Key(tcell.KeyF3) + KeyF4 = Key(tcell.KeyF4) + KeyF5 = Key(tcell.KeyF5) + KeyF6 = Key(tcell.KeyF6) + KeyF7 = Key(tcell.KeyF7) + KeyF8 = Key(tcell.KeyF8) + KeyF9 = Key(tcell.KeyF9) + KeyF10 = Key(tcell.KeyF10) + KeyF11 = Key(tcell.KeyF11) + KeyF12 = Key(tcell.KeyF12) + KeyInsert = Key(tcell.KeyInsert) + KeyDelete = Key(tcell.KeyDelete) + KeyHome = Key(tcell.KeyHome) + KeyEnd = Key(tcell.KeyEnd) + KeyPgdn = Key(tcell.KeyPgDn) + KeyPgup = Key(tcell.KeyPgUp) + KeyArrowUp = Key(tcell.KeyUp) + KeyShiftArrowUp = Key(tcell.KeyF62) + KeyArrowDown = Key(tcell.KeyDown) + KeyShiftArrowDown = Key(tcell.KeyF63) + KeyArrowLeft = Key(tcell.KeyLeft) + KeyArrowRight = Key(tcell.KeyRight) ) // Keys combinations. diff --git a/vendor/github.com/jesseduffield/gocui/tcell_driver.go b/vendor/github.com/jesseduffield/gocui/tcell_driver.go index a3153d4c7..96f24390f 100644 --- a/vendor/github.com/jesseduffield/gocui/tcell_driver.go +++ b/vendor/github.com/jesseduffield/gocui/tcell_driver.go @@ -300,6 +300,14 @@ func (g *Gui) pollEvent() GocuiEvent { mod = 0 ch = rune(0) k = tcell.KeyCtrlSpace + } else if mod == tcell.ModShift && k == tcell.KeyUp { + mod = 0 + ch = rune(0) + k = tcell.KeyF62 + } else if mod == tcell.ModShift && k == tcell.KeyDown { + mod = 0 + ch = rune(0) + k = tcell.KeyF63 } else if mod == tcell.ModCtrl || mod == tcell.ModShift { // remove Ctrl or Shift if specified // - shift - will be translated to the final code of rune diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index 51c968b14..6bfc4c487 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -41,6 +41,14 @@ type View struct { wx, wy int // Write() offsets lines [][]cell // All the data outMode OutputMode + // The y position of the first line of a range selection. + // This is not relative to the view's origin: it is relative to the first line + // of the view's content, so you can scroll the view and this value will remain + // the same, unlike the view's cy value. + // A value of -1 means that there is no range selection. + // This value can be greater than the selected line index, in the event that + // a user starts a range select and then moves the cursor up. + rangeSelectStartY int // readBuffer is used for storing unread bytes readBuffer []byte @@ -284,6 +292,14 @@ func (v *View) FocusPoint(cx int, cy int) { v.cy = cy - v.oy } +func (v *View) SetRangeSelectStart(rangeSelectStartY int) { + v.rangeSelectStartY = rangeSelectStartY +} + +func (v *View) CancelRangeSelect() { + v.rangeSelectStartY = -1 +} + func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int { if viewHeight > lineCount { return 0 @@ -349,19 +365,20 @@ func (l lineType) String() string { // newView returns a new View object. func newView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { v := &View{ - name: name, - x0: x0, - y0: y0, - x1: x1, - y1: y1, - Visible: true, - Frame: true, - Editor: DefaultEditor, - tainted: true, - outMode: mode, - ei: newEscapeInterpreter(mode), - searcher: &searcher{}, - TextArea: &TextArea{}, + name: name, + x0: x0, + y0: y0, + x1: x1, + y1: y1, + Visible: true, + Frame: true, + Editor: DefaultEditor, + tainted: true, + outMode: mode, + ei: newEscapeInterpreter(mode), + searcher: &searcher{}, + TextArea: &TextArea{}, + rangeSelectStartY: -1, } v.FgColor, v.BgColor = ColorDefault, ColorDefault @@ -428,11 +445,17 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) error { if x < 0 || x >= maxX || y < 0 || y >= maxY { return ErrInvalidPoint } - var ( - ry, rcy int - err error - ) - if v.Highlight { + + if v.Mask != 0 { + fgColor = v.FgColor + bgColor = v.BgColor + ch = v.Mask + } else if v.Highlight { + var ( + ry, rcy int + err error + ) + _, ry, err = v.realPosition(x, y) if err != nil { return err @@ -442,20 +465,28 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) error { if err == nil { rcy = rrcy } - } - if v.Mask != 0 { - fgColor = v.FgColor - bgColor = v.BgColor - ch = v.Mask - } else if v.Highlight && ry == rcy { - // this ensures we use the bright variant of a colour upon highlight - fgColorComponent := fgColor & ^AttrAll - if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 { - fgColor += 8 + rangeSelectStart := rcy + rangeSelectEnd := rcy + if v.rangeSelectStartY != -1 { + _, realRangeSelectStart, err := v.realPosition(0, v.rangeSelectStartY-v.oy) + if err != nil { + return err + } + + rangeSelectStart = min(realRangeSelectStart, rcy) + rangeSelectEnd = max(realRangeSelectStart, rcy) + } + + if ry >= rangeSelectStart && ry <= rangeSelectEnd { + // this ensures we use the bright variant of a colour upon highlight + fgColorComponent := fgColor & ^AttrAll + if fgColorComponent >= AttrIsValidColor && fgColorComponent < AttrIsValidColor+8 { + fgColor += 8 + } + fgColor = fgColor | AttrBold + bgColor = bgColor | v.SelBgColor } - fgColor = fgColor | AttrBold - bgColor = bgColor | v.SelBgColor } // Don't display NUL characters @@ -468,6 +499,20 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) error { return nil } +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + // SetCursor sets the cursor position of the view at the given point, // relative to the view. It checks if the position is valid. func (v *View) SetCursor(x, y int) error { @@ -1388,7 +1433,31 @@ func (v *View) SelectedLine() string { if len(v.lines) == 0 { return "" } - line := v.lines[v.SelectedLineIdx()] + + return v.lineContentAtIdx(v.SelectedLineIdx()) +} + +// expected to only be used in tests +func (v *View) SelectedLines() []string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + if len(v.lines) == 0 { + return nil + } + + startIdx, endIdx := v.SelectedLineRange() + + lines := make([]string, 0, endIdx-startIdx+1) + for i := startIdx; i <= endIdx; i++ { + lines = append(lines, v.lineContentAtIdx(i)) + } + + return lines +} + +func (v *View) lineContentAtIdx(idx int) string { + line := v.lines[idx] str := lineType(line).String() return strings.Replace(str, "\x00", "", -1) } @@ -1399,6 +1468,25 @@ func (v *View) SelectedPoint() (int, int) { return cx + ox, cy + oy } +func (v *View) SelectedLineRange() (int, int) { + _, cy := v.Cursor() + _, oy := v.Origin() + + start := cy + oy + + if v.rangeSelectStartY == -1 { + return start, start + } + + end := v.rangeSelectStartY + + if start > end { + return end, start + } else { + return start, end + } +} + func (v *View) RenderTextArea() { v.Clear() fmt.Fprint(v, v.TextArea.GetContent()) diff --git a/vendor/golang.org/x/sys/unix/mkerrors.sh b/vendor/golang.org/x/sys/unix/mkerrors.sh index 6202638ba..c6492020e 100644 --- a/vendor/golang.org/x/sys/unix/mkerrors.sh +++ b/vendor/golang.org/x/sys/unix/mkerrors.sh @@ -248,6 +248,7 @@ struct ltchars { #include #include #include +#include #include #include #include @@ -283,10 +284,6 @@ struct ltchars { #include #endif -#ifndef MSG_FASTOPEN -#define MSG_FASTOPEN 0x20000000 -#endif - #ifndef PTRACE_GETREGS #define PTRACE_GETREGS 0xc #endif @@ -295,14 +292,6 @@ struct ltchars { #define PTRACE_SETREGS 0xd #endif -#ifndef SOL_NETLINK -#define SOL_NETLINK 270 -#endif - -#ifndef SOL_SMC -#define SOL_SMC 286 -#endif - #ifdef SOL_BLUETOOTH // SPARC includes this in /usr/include/sparc64-linux-gnu/bits/socket.h // but it is already in bluetooth_linux.go @@ -319,10 +308,23 @@ struct ltchars { #undef TIPC_WAIT_FOREVER #define TIPC_WAIT_FOREVER 0xffffffff -// Copied from linux/l2tp.h -// Including linux/l2tp.h here causes conflicts between linux/in.h -// and netinet/in.h included via net/route.h above. -#define IPPROTO_L2TP 115 +// Copied from linux/netfilter/nf_nat.h +// Including linux/netfilter/nf_nat.h here causes conflicts between linux/in.h +// and netinet/in.h. +#define NF_NAT_RANGE_MAP_IPS (1 << 0) +#define NF_NAT_RANGE_PROTO_SPECIFIED (1 << 1) +#define NF_NAT_RANGE_PROTO_RANDOM (1 << 2) +#define NF_NAT_RANGE_PERSISTENT (1 << 3) +#define NF_NAT_RANGE_PROTO_RANDOM_FULLY (1 << 4) +#define NF_NAT_RANGE_PROTO_OFFSET (1 << 5) +#define NF_NAT_RANGE_NETMAP (1 << 6) +#define NF_NAT_RANGE_PROTO_RANDOM_ALL \ + (NF_NAT_RANGE_PROTO_RANDOM | NF_NAT_RANGE_PROTO_RANDOM_FULLY) +#define NF_NAT_RANGE_MASK \ + (NF_NAT_RANGE_MAP_IPS | NF_NAT_RANGE_PROTO_SPECIFIED | \ + NF_NAT_RANGE_PROTO_RANDOM | NF_NAT_RANGE_PERSISTENT | \ + NF_NAT_RANGE_PROTO_RANDOM_FULLY | NF_NAT_RANGE_PROTO_OFFSET | \ + NF_NAT_RANGE_NETMAP) // Copied from linux/hid.h. // Keep in sync with the size of the referenced fields. @@ -603,6 +605,9 @@ ccflags="$@" $2 ~ /^FSOPT_/ || $2 ~ /^WDIO[CFS]_/ || $2 ~ /^NFN/ || + $2 !~ /^NFT_META_IIFTYPE/ && + $2 ~ /^NFT_/ || + $2 ~ /^NF_NAT_/ || $2 ~ /^XDP_/ || $2 ~ /^RWF_/ || $2 ~ /^(HDIO|WIN|SMART)_/ || diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index c73cfe2f1..a5d3ff8df 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -2127,6 +2127,60 @@ const ( NFNL_SUBSYS_QUEUE = 0x3 NFNL_SUBSYS_ULOG = 0x4 NFS_SUPER_MAGIC = 0x6969 + NFT_CHAIN_FLAGS = 0x7 + NFT_CHAIN_MAXNAMELEN = 0x100 + NFT_CT_MAX = 0x17 + NFT_DATA_RESERVED_MASK = 0xffffff00 + NFT_DATA_VALUE_MAXLEN = 0x40 + NFT_EXTHDR_OP_MAX = 0x4 + NFT_FIB_RESULT_MAX = 0x3 + NFT_INNER_MASK = 0xf + NFT_LOGLEVEL_MAX = 0x8 + NFT_NAME_MAXLEN = 0x100 + NFT_NG_MAX = 0x1 + NFT_OBJECT_CONNLIMIT = 0x5 + NFT_OBJECT_COUNTER = 0x1 + NFT_OBJECT_CT_EXPECT = 0x9 + NFT_OBJECT_CT_HELPER = 0x3 + NFT_OBJECT_CT_TIMEOUT = 0x7 + NFT_OBJECT_LIMIT = 0x4 + NFT_OBJECT_MAX = 0xa + NFT_OBJECT_QUOTA = 0x2 + NFT_OBJECT_SECMARK = 0x8 + NFT_OBJECT_SYNPROXY = 0xa + NFT_OBJECT_TUNNEL = 0x6 + NFT_OBJECT_UNSPEC = 0x0 + NFT_OBJ_MAXNAMELEN = 0x100 + NFT_OSF_MAXGENRELEN = 0x10 + NFT_QUEUE_FLAG_BYPASS = 0x1 + NFT_QUEUE_FLAG_CPU_FANOUT = 0x2 + NFT_QUEUE_FLAG_MASK = 0x3 + NFT_REG32_COUNT = 0x10 + NFT_REG32_SIZE = 0x4 + NFT_REG_MAX = 0x4 + NFT_REG_SIZE = 0x10 + NFT_REJECT_ICMPX_MAX = 0x3 + NFT_RT_MAX = 0x4 + NFT_SECMARK_CTX_MAXLEN = 0x100 + NFT_SET_MAXNAMELEN = 0x100 + NFT_SOCKET_MAX = 0x3 + NFT_TABLE_F_MASK = 0x3 + NFT_TABLE_MAXNAMELEN = 0x100 + NFT_TRACETYPE_MAX = 0x3 + NFT_TUNNEL_F_MASK = 0x7 + NFT_TUNNEL_MAX = 0x1 + NFT_TUNNEL_MODE_MAX = 0x2 + NFT_USERDATA_MAXLEN = 0x100 + NFT_XFRM_KEY_MAX = 0x6 + NF_NAT_RANGE_MAP_IPS = 0x1 + NF_NAT_RANGE_MASK = 0x7f + NF_NAT_RANGE_NETMAP = 0x40 + NF_NAT_RANGE_PERSISTENT = 0x8 + NF_NAT_RANGE_PROTO_OFFSET = 0x20 + NF_NAT_RANGE_PROTO_RANDOM = 0x4 + NF_NAT_RANGE_PROTO_RANDOM_ALL = 0x14 + NF_NAT_RANGE_PROTO_RANDOM_FULLY = 0x10 + NF_NAT_RANGE_PROTO_SPECIFIED = 0x2 NILFS_SUPER_MAGIC = 0x3434 NL0 = 0x0 NL1 = 0x100 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go index a1d061597..9dc42410b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go index 5b2a74097..0d3a0751c 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go index f6eda1344..c39f7776d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go index 55df20ae9..57571d072 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go index 8c1155cbc..e62963e67 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go index 7cc80c58d..00831354c 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go index 0688737f4..79029ed58 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go @@ -2297,5 +2297,3 @@ func unveil(path *byte, flags *byte) (err error) { var libc_unveil_trampoline_addr uintptr //go:cgo_import_dynamic libc_unveil unveil "libc.so" - - diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index 47dc57967..ffb8708cc 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -194,6 +194,7 @@ func NewCallbackCDecl(fn interface{}) uintptr { //sys GetComputerName(buf *uint16, n *uint32) (err error) = GetComputerNameW //sys GetComputerNameEx(nametype uint32, buf *uint16, n *uint32) (err error) = GetComputerNameExW //sys SetEndOfFile(handle Handle) (err error) +//sys SetFileValidData(handle Handle, validDataLength int64) (err error) //sys GetSystemTimeAsFileTime(time *Filetime) //sys GetSystemTimePreciseAsFileTime(time *Filetime) //sys GetTimeZoneInformation(tzi *Timezoneinformation) (rc uint32, err error) [failretval==0xffffffff] diff --git a/vendor/golang.org/x/sys/windows/zsyscall_windows.go b/vendor/golang.org/x/sys/windows/zsyscall_windows.go index 146a1f019..e8791c82c 100644 --- a/vendor/golang.org/x/sys/windows/zsyscall_windows.go +++ b/vendor/golang.org/x/sys/windows/zsyscall_windows.go @@ -342,6 +342,7 @@ var ( procSetDefaultDllDirectories = modkernel32.NewProc("SetDefaultDllDirectories") procSetDllDirectoryW = modkernel32.NewProc("SetDllDirectoryW") procSetEndOfFile = modkernel32.NewProc("SetEndOfFile") + procSetFileValidData = modkernel32.NewProc("SetFileValidData") procSetEnvironmentVariableW = modkernel32.NewProc("SetEnvironmentVariableW") procSetErrorMode = modkernel32.NewProc("SetErrorMode") procSetEvent = modkernel32.NewProc("SetEvent") @@ -2988,6 +2989,14 @@ func SetEndOfFile(handle Handle) (err error) { return } +func SetFileValidData(handle Handle, validDataLength int64) (err error) { + r1, _, e1 := syscall.Syscall(procSetFileValidData.Addr(), 2, uintptr(handle), uintptr(validDataLength), 0) + if r1 == 0 { + err = errnoErr(e1) + } + return +} + func SetEnvironmentVariable(name *uint16, value *uint16) (err error) { r1, _, e1 := syscall.Syscall(procSetEnvironmentVariableW.Addr(), 2, uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(value)), 0) if r1 == 0 { diff --git a/vendor/modules.txt b/vendor/modules.txt index 1121027e0..071b257ad 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -173,7 +173,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem github.com/jesseduffield/go-git/v5/utils/merkletrie/index github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame github.com/jesseduffield/go-git/v5/utils/merkletrie/noder -# github.com/jesseduffield/gocui v0.3.1-0.20240103192639-2874168c14db +# github.com/jesseduffield/gocui v0.3.1-0.20240118234343-2d41754af383 ## explicit; go 1.12 github.com/jesseduffield/gocui # github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 @@ -314,13 +314,13 @@ golang.org/x/exp/slices golang.org/x/net/context golang.org/x/net/internal/socks golang.org/x/net/proxy -# golang.org/x/sys v0.15.0 +# golang.org/x/sys v0.16.0 ## explicit; go 1.18 golang.org/x/sys/cpu golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.15.0 +# golang.org/x/term v0.16.0 ## explicit; go 1.18 golang.org/x/term # golang.org/x/text v0.14.0 From c0c3aac02e048903764e4b42b513334e58855d73 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Thu, 11 Jan 2024 10:34:05 +1100 Subject: [PATCH 03/11] Support non-sticky range selection in patch explorer views --- pkg/gui/context/patch_explorer_context.go | 10 +++ .../controllers/patch_explorer_controller.go | 30 ++++++++- pkg/gui/patch_exploring/state.go | 62 +++++++++++++++++-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 54a9356ac..6f086e7f4 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -116,6 +116,16 @@ func (self *PatchExplorerContext) FocusSelection() { _ = view.SetOriginY(newOriginY) view.SetCursorY(state.GetSelectedLineIdx() - newOriginY) + + // At present this is just bookkeeping: the reason for setting this would be + // so that gocui knows which lines to highlight, but we're currently handling + // highlighting ourselves. + rangeStartLineIdx, isSelectingRange := state.RangeStartLineIdx() + if isSelectingRange { + view.SetRangeSelectStart(rangeStartLineIdx) + } else { + view.CancelRangeSelect() + } } func (self *PatchExplorerContext) GetContentToRender(isFocused bool) string { diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 2083c2943..caac1f51c 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -56,6 +56,18 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.withRenderAndFocus(self.HandleNextLine), }, + { + Tag: "navigation", + Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), + Handler: self.withRenderAndFocus(self.HandlePrevLineRange), + Description: self.c.Tr.RangeSelectUp, + }, + { + Tag: "navigation", + Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), + Handler: self.withRenderAndFocus(self.HandleNextLineRange), + Description: self.c.Tr.RangeSelectDown, + }, { Key: opts.GetKey(opts.Config.Universal.PrevBlock), Handler: self.withRenderAndFocus(self.HandlePrevHunk), @@ -177,6 +189,22 @@ func (self *PatchExplorerController) HandleNextLine() error { return nil } +func (self *PatchExplorerController) HandlePrevLineRange() error { + s := self.context.GetState() + + s.CycleRange(false) + + return nil +} + +func (self *PatchExplorerController) HandleNextLineRange() error { + s := self.context.GetState() + + s.CycleRange(true) + + return nil +} + func (self *PatchExplorerController) HandlePrevHunk() error { self.context.GetState().CycleHunk(false) @@ -190,7 +218,7 @@ func (self *PatchExplorerController) HandleNextHunk() error { } func (self *PatchExplorerController) HandleToggleSelectRange() error { - self.context.GetState().ToggleSelectRange() + self.context.GetState().ToggleStickySelectRange() return nil } diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 1c82d59cb..d54b7ec7c 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -12,9 +12,12 @@ import ( type State struct { selectedLineIdx int rangeStartLineIdx int - diff string - patch *patch.Patch - selectMode selectMode + // If a range is sticky, it means we expand the range when we move up or down. + // Otherwise, we cancel the range when we move up or down. + rangeIsSticky bool + diff string + patch *patch.Patch + selectMode selectMode } // these represent what select mode we're in @@ -46,10 +49,12 @@ func NewState(diff string, selectedLineIdx int, oldState *State, log *logrus.Ent } selectMode := LINE + rangeIsSticky := false // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line if selectedLineIdx >= 0 { selectMode = RANGE rangeStartLineIdx = selectedLineIdx + rangeIsSticky = true } else if oldState != nil { // if we previously had a selectMode of RANGE, we want that to now be line again if oldState.selectMode == HUNK { @@ -65,6 +70,7 @@ func NewState(diff string, selectedLineIdx int, oldState *State, log *logrus.Ent selectedLineIdx: selectedLineIdx, selectMode: selectMode, rangeStartLineIdx: rangeStartLineIdx, + rangeIsSticky: rangeIsSticky, diff: diff, } } @@ -85,15 +91,24 @@ func (s *State) ToggleSelectHunk() { } } -func (s *State) ToggleSelectRange() { +func (s *State) ToggleStickySelectRange() { + s.ToggleSelectRange(true) +} + +func (s *State) ToggleSelectRange(sticky bool) { if s.selectMode == RANGE { s.selectMode = LINE } else { s.selectMode = RANGE s.rangeStartLineIdx = s.selectedLineIdx + s.rangeIsSticky = sticky } } +func (s *State) SetRangeIsSticky(value bool) { + s.rangeIsSticky = value +} + func (s *State) SelectingHunk() bool { return s.selectMode == HUNK } @@ -110,7 +125,18 @@ func (s *State) SetLineSelectMode() { s.selectMode = LINE } +// For when you move the cursor without holding shift (meaning if we're in +// a non-sticky range select, we'll cancel it) func (s *State) SelectLine(newSelectedLineIdx int) { + if s.selectMode == RANGE && !s.rangeIsSticky { + s.selectMode = LINE + } + + s.selectLineWithoutRangeCheck(newSelectedLineIdx) +} + +// This just moves the cursor without caring about range select +func (s *State) selectLineWithoutRangeCheck(newSelectedLineIdx int) { if newSelectedLineIdx < 0 { newSelectedLineIdx = 0 } else if newSelectedLineIdx > s.patch.LineCount()-1 { @@ -124,8 +150,9 @@ func (s *State) SelectNewLineForRange(newSelectedLineIdx int) { s.rangeStartLineIdx = newSelectedLineIdx s.selectMode = RANGE + s.rangeIsSticky = true - s.SelectLine(newSelectedLineIdx) + s.selectLineWithoutRangeCheck(newSelectedLineIdx) } func (s *State) CycleSelection(forward bool) { @@ -161,6 +188,23 @@ func (s *State) CycleLine(forward bool) { s.SelectLine(s.selectedLineIdx + change) } +// This is called when we use shift+arrow to expand the range (i.e. a non-sticky +// range) +func (s *State) CycleRange(forward bool) { + if !s.SelectingRange() { + s.ToggleSelectRange(false) + } + + s.SetRangeIsSticky(false) + + change := 1 + if !forward { + change = -1 + } + + s.selectLineWithoutRangeCheck(s.selectedLineIdx + change) +} + // returns first and last patch line index of current hunk func (s *State) CurrentHunkBounds() (int, int) { hunkIdx := s.patch.HunkContainingLine(s.selectedLineIdx) @@ -226,3 +270,11 @@ func (s *State) CalculateOrigin(currentOrigin int, bufferHeight int, numLines in return calculateOrigin(currentOrigin, bufferHeight, numLines, firstLineIdx, lastLineIdx, s.GetSelectedLineIdx(), s.selectMode) } + +func (s *State) RangeStartLineIdx() (int, bool) { + if s.selectMode == RANGE { + return s.rangeStartLineIdx, true + } + + return 0, false +} From f3eb180f75496637719895902abf76af10b8425f Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 14 Jan 2024 00:18:05 +1100 Subject: [PATCH 04/11] Standardise display of range selection across views We're not fully standardising here: different contexts can store their range state however they like. What we are standardising on is that now the view is always responsible for highlighting the selected lines, meaning the context/controller needs to tell the view where the range start is. Two convenient benefits from this change: 1) we no longer need bespoke code in integration tests for asserting on selected lines because we can just ask the view 2) line selection in staging/patch-building/merge-conflicts views now look the same as in list views i.e. the highlight applies to the whole line (including trailing space) I also noticed a bug with merge conflicts not rendering the selection on focus though I suspect it wasn't a bug with any real consequences when the view wasn't displaying the selection. I'm going to scrap the selectedRangeBgColor config and just let it use the single line background color. Hopefully nobody cares, but there's really no need for an extra config. --- docs/Config.md | 8 +- docs/keybindings/Keybindings_en.md | 9 +- docs/keybindings/Keybindings_ja.md | 5 +- docs/keybindings/Keybindings_ko.md | 5 +- docs/keybindings/Keybindings_nl.md | 5 +- docs/keybindings/Keybindings_pl.md | 9 +- docs/keybindings/Keybindings_ru.md | 5 +- docs/keybindings/Keybindings_zh-CN.md | 5 +- docs/keybindings/Keybindings_zh-TW.md | 5 +- pkg/commands/patch/format.go | 24 +-- pkg/commands/patch/patch_builder.go | 4 +- pkg/config/user_config.go | 4 - pkg/gui/context/merge_conflicts_context.go | 27 +++- pkg/gui/context/patch_explorer_context.go | 15 +- pkg/gui/controllers/files_controller.go | 2 +- .../helpers/merge_conflicts_helper.go | 10 +- .../controllers/merge_conflicts_controller.go | 14 +- pkg/gui/layout.go | 10 -- pkg/gui/mergeconflicts/rendering.go | 10 +- pkg/gui/patch_exploring/state.go | 4 - pkg/gui/views.go | 6 +- pkg/integration/components/view_driver.go | 88 +++-------- pkg/integration/components/views.go | 95 +----------- pkg/integration/tests/test_list.go | 1 + pkg/integration/tests/ui/range_select.go | 142 ++++++++++++++++++ pkg/theme/theme.go | 4 - schema/config.json | 24 +-- test/default_test_config/config.yml | 2 - 28 files changed, 255 insertions(+), 287 deletions(-) create mode 100644 pkg/integration/tests/ui/range_select.go diff --git a/docs/Config.md b/docs/Config.md index 8b8b62c11..966fce0f4 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -57,8 +57,6 @@ gui: - blue selectedLineBgColor: - blue # set to `default` to have no background colour - selectedRangeBgColor: - - blue cherryPickedCommitBgColor: - cyan cherryPickedCommitFgColor: @@ -390,15 +388,13 @@ The available attributes are: ## Highlighting the selected line -If you don't like the default behaviour of highlighting the selected line with a blue background, you can use the `selectedLineBgColor` and `selectedRangeBgColor` keys to customise the behaviour. If you just want to embolden the selected line (this was the original default), you can do the following: +If you don't like the default behaviour of highlighting the selected line with a blue background, you can use the `selectedLineBgColor` key to customise the behaviour. If you just want to embolden the selected line (this was the original default), you can do the following: ```yaml gui: theme: selectedLineBgColor: - default - selectedRangeBgColor: - - default ``` You can also use the reverse attribute like so: @@ -408,8 +404,6 @@ gui: theme: selectedLineBgColor: - reverse - selectedRangeBgColor: - - reverse ``` ## Custom Author Color diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 9ffd28746..b96d22ec6 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ .: Next page <: Scroll to top >: Scroll to bottom + v: Toggle range select + <s-down>: Range select down + <s-up>: Range select up /: Search the current view by text H: Scroll left L: Scroll right @@ -196,8 +199,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: Select previous hunk
   <right>: Select next hunk
-  v: Toggle drag select
-  V: Toggle drag select
+  v: Toggle range select
   a: Toggle select hunk
   <c-o>: Copy the selected text to the clipboard
   o: Open file
@@ -212,8 +214,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
 
   <left>: Select previous hunk
   <right>: Select next hunk
-  v: Toggle drag select
-  V: Toggle drag select
+  v: Toggle range select
   a: Toggle select hunk
   <c-o>: Copy the selected text to the clipboard
   o: Open file
diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md
index f2f26db5a..4365e3685 100644
--- a/docs/keybindings/Keybindings_ja.md
+++ b/docs/keybindings/Keybindings_ja.md
@@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   .: 次のページ
   <: 最上部までスクロール
   >: 最下部までスクロール
+  v: 範囲選択を切り替え
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: 検索を開始
   H: 左スクロール
   L: 右スクロール
@@ -270,7 +273,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 前のhunkを選択
   <right>: 次のhunkを選択
   v: 範囲選択を切り替え
-  V: 範囲選択を切り替え
   a: Hunk選択を切り替え
   <c-o>: 選択されたテキストをクリップボードにコピー
   o: ファイルを開く
@@ -286,7 +288,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 前のhunkを選択
   <right>: 次のhunkを選択
   v: 範囲選択を切り替え
-  V: 範囲選択を切り替え
   a: Hunk選択を切り替え
   <c-o>: 選択されたテキストをクリップボードにコピー
   o: ファイルを開く
diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md
index 36c045ba1..7d60e1b07 100644
--- a/docs/keybindings/Keybindings_ko.md
+++ b/docs/keybindings/Keybindings_ko.md
@@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   .: 다음 페이지
   <: 맨 위로 스크롤 
   >: 맨 아래로 스크롤 
+  v: 드래그 선택 전환
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: 검색 시작
   H: 우 스크롤
   L: 좌 스크롤
@@ -141,7 +144,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 이전 hunk를 선택
   <right>: 다음 hunk를 선택
   v: 드래그 선택 전환
-  V: 드래그 선택 전환
   a: Toggle select hunk
   <c-o>: 선택한 텍스트를 클립보드에 복사
   o: 파일 닫기
@@ -157,7 +159,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 이전 hunk를 선택
   <right>: 다음 hunk를 선택
   v: 드래그 선택 전환
-  V: 드래그 선택 전환
   a: Toggle select hunk
   <c-o>: 선택한 텍스트를 클립보드에 복사
   o: 파일 닫기
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index bd78ff694..1a1d432d5 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   .: Volgende pagina
   <: Scroll naar boven
   >: Scroll naar beneden
+  v: Toggle drag selecteer
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: Start met zoeken
   H: Scroll left
   L: Scroll right
@@ -205,7 +208,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: Selecteer de vorige hunk
   <right>: Selecteer de volgende hunk
   v: Toggle drag selecteer
-  V: Toggle drag selecteer
   a: Toggle selecteer hunk
   <c-o>: Copy the selected text to the clipboard
   o: Open bestand
@@ -266,7 +268,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: Selecteer de vorige hunk
   <right>: Selecteer de volgende hunk
   v: Toggle drag selecteer
-  V: Toggle drag selecteer
   a: Toggle selecteer hunk
   <c-o>: Copy the selected text to the clipboard
   o: Open bestand
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index 048dd490e..3290d6e38 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   .: Next page
   <: Scroll to top
   >: Scroll to bottom
+  v: Toggle range select
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: Search the current view by text
   H: Scroll left
   L: Scroll right
@@ -127,8 +130,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
 
   <left>: Poprzedni kawałek
   <right>: Następny kawałek
-  v: Toggle drag select
-  V: Toggle drag select
+  v: Toggle range select
   a: Toggle select hunk
   <c-o>: Copy the selected text to the clipboard
   o: Otwórz plik
@@ -197,8 +199,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
 
   <left>: Poprzedni kawałek
   <right>: Następny kawałek
-  v: Toggle drag select
-  V: Toggle drag select
+  v: Toggle range select
   a: Toggle select hunk
   <c-o>: Copy the selected text to the clipboard
   o: Otwórz plik
diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md
index ba7af912f..76cec5c5f 100644
--- a/docs/keybindings/Keybindings_ru.md
+++ b/docs/keybindings/Keybindings_ru.md
@@ -37,6 +37,9 @@ _Связки клавиш_
   .: Следующая страница
   <: Пролистать наверх
   >: Прокрутить вниз
+  v: Переключить выборку перетаскивания
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: Найти
   H: Прокрутить влево
   L: Прокрутить вправо
@@ -61,7 +64,6 @@ _Связки клавиш_
   <left>: Выбрать предыдущую часть
   <right>: Выбрать следующую часть
   v: Переключить выборку перетаскивания
-  V: Переключить выборку перетаскивания
   a: Переключить выборку частей
   <c-o>: Скопировать выделенный текст в буфер обмена
   o: Открыть файл
@@ -106,7 +108,6 @@ _Связки клавиш_
   <left>: Выбрать предыдущую часть
   <right>: Выбрать следующую часть
   v: Переключить выборку перетаскивания
-  V: Переключить выборку перетаскивания
   a: Переключить выборку частей
   <c-o>: Скопировать выделенный текст в буфер обмена
   o: Открыть файл
diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md
index eaa7c725f..333cfada5 100644
--- a/docs/keybindings/Keybindings_zh-CN.md
+++ b/docs/keybindings/Keybindings_zh-CN.md
@@ -37,6 +37,9 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   .: 下一页
   <: 滚动到顶部
   >: 滚动到底部
+  v: 切换拖动选择
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: 开始搜索
   H: 向左滚动
   L: 向右滚动
@@ -229,7 +232,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 选择上一个区块
   <right>: 选择下一个区块
   v: 切换拖动选择
-  V: 切换拖动选择
   a: 切换选择区块
   <c-o>: 将选中文本复制到剪贴板
   o: 打开文件
@@ -274,7 +276,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 选择上一个区块
   <right>: 选择下一个区块
   v: 切换拖动选择
-  V: 切换拖动选择
   a: 切换选择区块
   <c-o>: 将选中文本复制到剪贴板
   o: 打开文件
diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md
index 9f623678b..81a2c4248 100644
--- a/docs/keybindings/Keybindings_zh-TW.md
+++ b/docs/keybindings/Keybindings_zh-TW.md
@@ -37,6 +37,9 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   .: 下一頁
   <: 捲動到頂部
   >: 捲動到底部
+  v: 切換拖曳選擇
+  <s-down>: Range select down
+  <s-up>: Range select up
   /: 開始搜尋
   H: 向左捲動
   L: 向右捲動
@@ -102,7 +105,6 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   <left>: 選擇上一段
   <right>: 選擇下一段
   v: 切換拖曳選擇
-  V: 切換拖曳選擇
   a: 切換選擇程式碼塊
   <c-o>: 複製所選文本至剪貼簿
   o: 開啟檔案
@@ -124,7 +126,6 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   <left>: 選擇上一段
   <right>: 選擇下一段
   v: 切換拖曳選擇
-  V: 切換拖曳選擇
   a: 切換選擇程式碼塊
   <c-o>: 複製所選文本至剪貼簿
   o: 開啟檔案
diff --git a/pkg/commands/patch/format.go b/pkg/commands/patch/format.go
index d04b6bec1..c61c8ef05 100644
--- a/pkg/commands/patch/format.go
+++ b/pkg/commands/patch/format.go
@@ -14,11 +14,6 @@ type patchPresenter struct {
 	// if true, all following fields are ignored
 	plain bool
 
-	isFocused bool
-	// first line index for selected cursor range
-	firstLineIndex int
-	// last line index for selected cursor range
-	lastLineIndex int
 	// line indices for tagged lines (e.g. lines added to a custom patch)
 	incLineIndices *set.Set[int]
 }
@@ -44,11 +39,6 @@ func formatRangePlain(patch *Patch, startIdx int, endIdx int) string {
 }
 
 type FormatViewOpts struct {
-	IsFocused bool
-	// first line index for selected cursor range
-	FirstLineIndex int
-	// last line index for selected cursor range
-	LastLineIndex int
 	// line indices for tagged lines (e.g. lines added to a custom patch)
 	IncLineIndices *set.Set[int]
 }
@@ -63,9 +53,6 @@ func formatView(patch *Patch, opts FormatViewOpts) string {
 	presenter := &patchPresenter{
 		patch:          patch,
 		plain:          false,
-		isFocused:      opts.IsFocused,
-		firstLineIndex: opts.FirstLineIndex,
-		lastLineIndex:  opts.LastLineIndex,
 		incLineIndices: includedLineIndices,
 	}
 	return presenter.format()
@@ -112,7 +99,6 @@ func (self *patchPresenter) format() string {
 				self.formatLineAux(
 					hunk.headerContext,
 					theme.DefaultTextColor,
-					lineIdx,
 					false,
 				),
 		)
@@ -139,23 +125,17 @@ func (self *patchPresenter) patchLineStyle(patchLine *PatchLine) style.TextStyle
 func (self *patchPresenter) formatLine(str string, textStyle style.TextStyle, index int) string {
 	included := self.incLineIndices.Includes(index)
 
-	return self.formatLineAux(str, textStyle, index, included)
+	return self.formatLineAux(str, textStyle, included)
 }
 
 // 'selected' means you've got it highlighted with your cursor
 // 'included' means the line has been included in the patch (only applicable when
 // building a patch)
-func (self *patchPresenter) formatLineAux(str string, textStyle style.TextStyle, index int, included bool) string {
+func (self *patchPresenter) formatLineAux(str string, textStyle style.TextStyle, included bool) string {
 	if self.plain {
 		return str
 	}
 
-	selected := self.isFocused && index >= self.firstLineIndex && index <= self.lastLineIndex
-
-	if selected {
-		textStyle = textStyle.MergeStyle(theme.SelectedRangeBgColor)
-	}
-
 	firstCharStyle := textStyle
 	if included {
 		firstCharStyle = firstCharStyle.MergeStyle(style.BgGreen)
diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go
index 2f350a40b..88f1becc5 100644
--- a/pkg/commands/patch/patch_builder.go
+++ b/pkg/commands/patch/patch_builder.go
@@ -197,9 +197,7 @@ func (p *PatchBuilder) RenderPatchForFile(filename string, plain bool, reverse b
 	if plain {
 		return patch.FormatPlain()
 	} else {
-		return patch.FormatView(FormatViewOpts{
-			IsFocused: false,
-		})
+		return patch.FormatView(FormatViewOpts{})
 	}
 }
 
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index d7bea4f03..a966905d0 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -154,9 +154,6 @@ type ThemeConfig struct {
 	// Background color of selected line.
 	// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
 	SelectedLineBgColor []string `yaml:"selectedLineBgColor" jsonschema:"minItems=1,uniqueItems=true"`
-	// Background color of selected range
-	// See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
-	SelectedRangeBgColor []string `yaml:"selectedRangeBgColor" jsonschema:"minItems=1,uniqueItems=true"`
 	// Foreground color of copied commit
 	CherryPickedCommitFgColor []string `yaml:"cherryPickedCommitFgColor" jsonschema:"minItems=1,uniqueItems=true"`
 	// Background color of copied commit
@@ -622,7 +619,6 @@ func GetDefaultConfig() *UserConfig {
 				InactiveBorderColor:        []string{"default"},
 				OptionsTextColor:           []string{"blue"},
 				SelectedLineBgColor:        []string{"blue"},
-				SelectedRangeBgColor:       []string{"blue"},
 				CherryPickedCommitBgColor:  []string{"cyan"},
 				CherryPickedCommitFgColor:  []string{"blue"},
 				MarkedBaseCommitBgColor:    []string{"yellow"},
diff --git a/pkg/gui/context/merge_conflicts_context.go b/pkg/gui/context/merge_conflicts_context.go
index 60aac6e3a..1cfe3c50a 100644
--- a/pkg/gui/context/merge_conflicts_context.go
+++ b/pkg/gui/context/merge_conflicts_context.go
@@ -68,8 +68,8 @@ func (self *MergeConflictsContext) IsUserScrolling() bool {
 	return self.viewModel.userVerticalScrolling
 }
 
-func (self *MergeConflictsContext) RenderAndFocus(isFocused bool) error {
-	self.setContent(isFocused)
+func (self *MergeConflictsContext) RenderAndFocus() error {
+	self.setContent()
 	self.FocusSelection()
 
 	self.c.Render()
@@ -77,30 +77,41 @@ func (self *MergeConflictsContext) RenderAndFocus(isFocused bool) error {
 	return nil
 }
 
-func (self *MergeConflictsContext) Render(isFocused bool) error {
-	self.setContent(isFocused)
+func (self *MergeConflictsContext) Render() error {
+	self.setContent()
 
 	self.c.Render()
 
 	return nil
 }
 
-func (self *MergeConflictsContext) GetContentToRender(isFocused bool) string {
+func (self *MergeConflictsContext) GetContentToRender() string {
 	if self.GetState() == nil {
 		return ""
 	}
 
-	return mergeconflicts.ColoredConflictFile(self.GetState(), isFocused)
+	return mergeconflicts.ColoredConflictFile(self.GetState())
 }
 
-func (self *MergeConflictsContext) setContent(isFocused bool) {
-	self.GetView().SetContent(self.GetContentToRender(isFocused))
+func (self *MergeConflictsContext) setContent() {
+	self.GetView().SetContent(self.GetContentToRender())
 }
 
 func (self *MergeConflictsContext) FocusSelection() {
 	if !self.IsUserScrolling() {
 		_ = self.GetView().SetOriginY(self.GetOriginY())
 	}
+
+	self.SetSelectedLineRange()
+}
+
+func (self *MergeConflictsContext) SetSelectedLineRange() {
+	startIdx, endIdx := self.GetState().GetSelectedRange()
+	view := self.GetView()
+	originY := view.OriginY()
+	// As far as the view is concerned, we are always selecting a range
+	view.SetRangeSelectStart(startIdx)
+	view.SetCursorY(endIdx - originY)
 }
 
 func (self *MergeConflictsContext) GetOriginY() int {
diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go
index 6f086e7f4..34f70e2c7 100644
--- a/pkg/gui/context/patch_explorer_context.go
+++ b/pkg/gui/context/patch_explorer_context.go
@@ -115,17 +115,10 @@ func (self *PatchExplorerContext) FocusSelection() {
 
 	_ = view.SetOriginY(newOriginY)
 
-	view.SetCursorY(state.GetSelectedLineIdx() - newOriginY)
-
-	// At present this is just bookkeeping: the reason for setting this would be
-	// so that gocui knows which lines to highlight, but we're currently handling
-	// highlighting ourselves.
-	rangeStartLineIdx, isSelectingRange := state.RangeStartLineIdx()
-	if isSelectingRange {
-		view.SetRangeSelectStart(rangeStartLineIdx)
-	} else {
-		view.CancelRangeSelect()
-	}
+	startIdx, endIdx := state.SelectedRange()
+	// As far as the view is concerned, we are always selecting a range
+	view.SetRangeSelectStart(startIdx)
+	view.SetCursorY(endIdx - newOriginY)
 }
 
 func (self *PatchExplorerContext) GetContentToRender(isFocused bool) string {
diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go
index 7776da5b3..7fb21dda8 100644
--- a/pkg/gui/controllers/files_controller.go
+++ b/pkg/gui/controllers/files_controller.go
@@ -205,7 +205,7 @@ func (self *FilesController) GetOnRenderToMain() func() error {
 				}
 
 				if hasConflicts {
-					return self.c.Helpers().MergeConflicts.Render(false)
+					return self.c.Helpers().MergeConflicts.Render()
 				}
 			}
 
diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go
index e6a56bfae..31f7bd5e3 100644
--- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go
+++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go
@@ -69,14 +69,14 @@ func (self *MergeConflictsHelper) EscapeMerge() error {
 	return nil
 }
 
-func (self *MergeConflictsHelper) SetConflictsAndRender(path string, isFocused bool) (bool, error) {
+func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) {
 	hasConflicts, err := self.setMergeStateWithoutLock(path)
 	if err != nil {
 		return false, err
 	}
 
 	if hasConflicts {
-		return true, self.context().Render(isFocused)
+		return true, self.context().Render()
 	}
 
 	return false, nil
@@ -100,8 +100,8 @@ func (self *MergeConflictsHelper) context() *context.MergeConflictsContext {
 	return self.c.Contexts().MergeConflicts
 }
 
-func (self *MergeConflictsHelper) Render(isFocused bool) error {
-	content := self.context().GetContentToRender(isFocused)
+func (self *MergeConflictsHelper) Render() error {
+	content := self.context().GetContentToRender()
 
 	var task types.UpdateTask
 	if self.context().IsUserScrolling() {
@@ -127,7 +127,7 @@ func (self *MergeConflictsHelper) RefreshMergeState() error {
 		return nil
 	}
 
-	hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath(), true)
+	hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath())
 	if err != nil {
 		return self.c.Error(err)
 	}
diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go
index 86f49489c..450cd1816 100644
--- a/pkg/gui/controllers/merge_conflicts_controller.go
+++ b/pkg/gui/controllers/merge_conflicts_controller.go
@@ -145,7 +145,13 @@ func (self *MergeConflictsController) GetOnFocus() func(types.OnFocusOpts) error
 	return func(types.OnFocusOpts) error {
 		self.c.Views().MergeConflicts.Wrap = false
 
-		return self.c.Helpers().MergeConflicts.Render(true)
+		if err := self.c.Helpers().MergeConflicts.Render(); err != nil {
+			return err
+		}
+
+		self.context().SetSelectedLineRange()
+
+		return nil
 	}
 }
 
@@ -313,17 +319,13 @@ func (self *MergeConflictsController) onLastConflictResolved() error {
 	return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
 }
 
-func (self *MergeConflictsController) isFocused() bool {
-	return self.c.CurrentContext().GetKey() == self.context().GetKey()
-}
-
 func (self *MergeConflictsController) withRenderAndFocus(f func() error) func() error {
 	return self.withLock(func() error {
 		if err := f(); err != nil {
 			return err
 		}
 
-		return self.context().RenderAndFocus(self.isFocused())
+		return self.context().RenderAndFocus()
 	})
 }
 
diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go
index 7b43f8aaa..02c74b023 100644
--- a/pkg/gui/layout.go
+++ b/pkg/gui/layout.go
@@ -4,7 +4,6 @@ import (
 	"github.com/jesseduffield/gocui"
 	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
-	"github.com/jesseduffield/lazygit/pkg/theme"
 	"github.com/samber/lo"
 	"golang.org/x/exp/slices"
 )
@@ -143,15 +142,6 @@ func (gui *Gui) layout(g *gocui.Gui) error {
 		gui.State.ViewsSetup = true
 	}
 
-	for _, listContext := range gui.c.Context().AllList() {
-		view, err := gui.g.View(listContext.GetViewName())
-		if err != nil {
-			continue
-		}
-
-		view.SelBgColor = theme.GocuiSelectedLineBgColor
-	}
-
 	mainViewWidth, mainViewHeight := gui.Views.Main.Size()
 	if mainViewWidth != gui.PrevLayout.MainWidth || mainViewHeight != gui.PrevLayout.MainHeight {
 		gui.PrevLayout.MainWidth = mainViewWidth
diff --git a/pkg/gui/mergeconflicts/rendering.go b/pkg/gui/mergeconflicts/rendering.go
index 54fc4e836..e57754e4b 100644
--- a/pkg/gui/mergeconflicts/rendering.go
+++ b/pkg/gui/mergeconflicts/rendering.go
@@ -8,7 +8,7 @@ import (
 	"github.com/jesseduffield/lazygit/pkg/utils"
 )
 
-func ColoredConflictFile(state *State, hasFocus bool) string {
+func ColoredConflictFile(state *State) string {
 	content := state.GetContent()
 	if len(state.conflicts) == 0 {
 		return content
@@ -21,9 +21,6 @@ func ColoredConflictFile(state *State, hasFocus bool) string {
 			textStyle = style.FgRed
 		}
 
-		if hasFocus && state.conflictIndex < len(state.conflicts) && *state.conflicts[state.conflictIndex] == *conflict && shouldHighlightLine(i, conflict, state.Selection()) {
-			textStyle = textStyle.MergeStyle(theme.SelectedRangeBgColor).SetBold()
-		}
 		if i == conflict.end && len(remainingConflicts) > 0 {
 			conflict, remainingConflicts = shiftConflict(remainingConflicts)
 		}
@@ -35,8 +32,3 @@ func ColoredConflictFile(state *State, hasFocus bool) string {
 func shiftConflict(conflicts []*mergeConflict) (*mergeConflict, []*mergeConflict) {
 	return conflicts[0], conflicts[1:]
 }
-
-func shouldHighlightLine(index int, conflict *mergeConflict, selection Selection) bool {
-	selectionStart, selectionEnd := selection.bounds(conflict)
-	return index >= selectionStart && index <= selectionEnd
-}
diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go
index d54b7ec7c..ccd30d03f 100644
--- a/pkg/gui/patch_exploring/state.go
+++ b/pkg/gui/patch_exploring/state.go
@@ -240,12 +240,8 @@ func (s *State) AdjustSelectedLineIdx(change int) {
 }
 
 func (s *State) RenderForLineIndices(isFocused bool, includedLineIndices []int) string {
-	firstLineIdx, lastLineIdx := s.SelectedRange()
 	includedLineIndicesSet := set.NewFromSlice(includedLineIndices)
 	return s.patch.FormatView(patch.FormatViewOpts{
-		IsFocused:      isFocused,
-		FirstLineIndex: firstLineIdx,
-		LastLineIndex:  lastLineIdx,
 		IncLineIndices: includedLineIndicesSet,
 	})
 }
diff --git a/pkg/gui/views.go b/pkg/gui/views.go
index 1c9748486..be279c9d4 100644
--- a/pkg/gui/views.go
+++ b/pkg/gui/views.go
@@ -91,6 +91,7 @@ func (gui *Gui) createAllViews() error {
 		}
 		(*mapping.viewPtr).FrameRunes = frameRunes
 		(*mapping.viewPtr).FgColor = theme.GocuiDefaultTextColor
+		(*mapping.viewPtr).SelBgColor = theme.GocuiSelectedLineBgColor
 	}
 
 	gui.Views.Options.FgColor = theme.OptionsColor
@@ -134,23 +135,18 @@ func (gui *Gui) createAllViews() error {
 	}
 
 	gui.Views.Staging.Title = gui.c.Tr.UnstagedChanges
-	gui.Views.Staging.Highlight = false
 	gui.Views.Staging.Wrap = true
 
 	gui.Views.StagingSecondary.Title = gui.c.Tr.StagedChanges
-	gui.Views.StagingSecondary.Highlight = false
 	gui.Views.StagingSecondary.Wrap = true
 
 	gui.Views.PatchBuilding.Title = gui.Tr.Patch
-	gui.Views.PatchBuilding.Highlight = false
 	gui.Views.PatchBuilding.Wrap = true
 
 	gui.Views.PatchBuildingSecondary.Title = gui.Tr.CustomPatch
-	gui.Views.PatchBuildingSecondary.Highlight = false
 	gui.Views.PatchBuildingSecondary.Wrap = true
 
 	gui.Views.MergeConflicts.Title = gui.c.Tr.MergeConflictsTitle
-	gui.Views.MergeConflicts.Highlight = false
 	gui.Views.MergeConflicts.Wrap = false
 
 	gui.Views.Limit.Title = gui.c.Tr.NotEnoughSpace
diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go
index b5e985155..437e647be 100644
--- a/pkg/integration/components/view_driver.go
+++ b/pkg/integration/components/view_driver.go
@@ -10,45 +10,24 @@ import (
 
 type ViewDriver struct {
 	// context is prepended to any error messages e.g. 'context: "current view"'
-	context              string
-	getView              func() *gocui.View
-	t                    *TestDriver
-	getSelectedLinesFn   func() ([]string, error)
-	getSelectedRangeFn   func() (int, int, error)
-	getSelectedLineIdxFn func() (int, error)
+	context string
+	getView func() *gocui.View
+	t       *TestDriver
 }
 
-func (self *ViewDriver) getSelectedLines() ([]string, error) {
-	if self.getSelectedLinesFn == nil {
-		view := self.t.gui.View(self.getView().Name())
-
-		return []string{view.SelectedLine()}, nil
-	}
-
-	return self.getSelectedLinesFn()
+func (self *ViewDriver) getSelectedLines() []string {
+	view := self.t.gui.View(self.getView().Name())
+	return view.SelectedLines()
 }
 
-func (self *ViewDriver) getSelectedRange() (int, int, error) {
-	if self.getSelectedRangeFn == nil {
-		view := self.t.gui.View(self.getView().Name())
-		idx := view.SelectedLineIdx()
-
-		return idx, idx, nil
-	}
-
-	return self.getSelectedRangeFn()
+func (self *ViewDriver) getSelectedRange() (int, int) {
+	view := self.t.gui.View(self.getView().Name())
+	return view.SelectedLineRange()
 }
 
-// even if you have a selected range, there may still be a line within that range
-// which the cursor points at. This function returns that line index.
-func (self *ViewDriver) getSelectedLineIdx() (int, error) {
-	if self.getSelectedLineIdxFn == nil {
-		view := self.t.gui.View(self.getView().Name())
-
-		return view.SelectedLineIdx(), nil
-	}
-
-	return self.getSelectedLineIdxFn()
+func (self *ViewDriver) getSelectedLineIdx() int {
+	view := self.t.gui.View(self.getView().Name())
+	return view.SelectedLineIdx()
 }
 
 // asserts that the view has the expected title
@@ -105,7 +84,7 @@ func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
 		content := self.getView().Buffer()
 		lines := strings.Split(content, "\n")
 
-		startIdx, endIdx, err := self.getSelectedRange()
+		startIdx, endIdx := self.getSelectedRange()
 
 		for i := 0; i < len(lines)-len(matchers)+1; i++ {
 			matches := true
@@ -118,10 +97,6 @@ func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
 					break
 				}
 				if checkIsSelected {
-					if err != nil {
-						matches = false
-						break
-					}
 					if lineIdx < startIdx || lineIdx > endIdx {
 						matches = false
 						break
@@ -181,10 +156,7 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver {
 	self.validateEnoughLines(matchers)
 
 	self.t.assertWithRetries(func() (bool, string) {
-		selectedLines, err := self.getSelectedLines()
-		if err != nil {
-			return false, err.Error()
-		}
+		selectedLines := self.getSelectedLines()
 
 		selectedContent := strings.Join(selectedLines, "\n")
 		expectedContent := expectedContentFromMatchers(matchers)
@@ -251,19 +223,13 @@ func (self *ViewDriver) assertLines(offset int, matchers ...*TextMatcher) *ViewD
 
 		if checkIsSelected {
 			self.t.assertWithRetries(func() (bool, string) {
-				startIdx, endIdx, err := self.getSelectedRange()
-				if err != nil {
-					return false, err.Error()
-				}
+				startIdx, endIdx := self.getSelectedRange()
 
 				if lineIdx < startIdx || lineIdx > endIdx {
 					if startIdx == endIdx {
 						return false, fmt.Sprintf("Unexpected selected line index in view '%s'. Expected %d, got %d", view.Name(), lineIdx, startIdx)
 					} else {
-						lines, err := self.getSelectedLines()
-						if err != nil {
-							return false, err.Error()
-						}
+						lines := self.getSelectedLines()
 						return false, fmt.Sprintf("Unexpected selected line index in view '%s'. Expected line %d to be in range %d to %d. Selected lines:\n---\n%s\n---\n\nExpected line: '%s'", view.Name(), lineIdx, startIdx, endIdx, strings.Join(lines, "\n"), matcher.name())
 					}
 				}
@@ -286,15 +252,11 @@ func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver {
 	return self
 }
 
-// asserts on the selected line of the view. If your view has multiple lines selected,
-// but also has a concept of a cursor position, this will assert on the line that
-// the cursor is on. Otherwise it will assert on the first line of the selection.
+// asserts on the selected line of the view. If you are selecting a range,
+// you should use the SelectedLines method instead.
 func (self *ViewDriver) SelectedLine(matcher *TextMatcher) *ViewDriver {
 	self.t.assertWithRetries(func() (bool, string) {
-		selectedLineIdx, err := self.getSelectedLineIdx()
-		if err != nil {
-			return false, err.Error()
-		}
+		selectedLineIdx := self.getSelectedLineIdx()
 
 		viewLines := self.getView().BufferLines()
 
@@ -480,11 +442,7 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
 		}
 	})
 
-	selectedLineIdx, err := self.getSelectedLineIdx()
-	if err != nil {
-		self.t.fail(err.Error())
-		return self
-	}
+	selectedLineIdx := self.getSelectedLineIdx()
 	if selectedLineIdx == matchIndex {
 		return self.SelectedLine(matcher)
 	}
@@ -507,11 +465,7 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
 
 	for i := 0; i < maxNumKeyPresses; i++ {
 		keyPress()
-		idx, err := self.getSelectedLineIdx()
-		if err != nil {
-			self.t.fail(err.Error())
-			return self
-		}
+		idx := self.getSelectedLineIdx()
 		if ok, _ := matcher.test(lines[idx]); ok {
 			return self
 		}
diff --git a/pkg/integration/components/views.go b/pkg/integration/components/views.go
index c7655da74..edb2b85b6 100644
--- a/pkg/integration/components/views.go
+++ b/pkg/integration/components/views.go
@@ -2,11 +2,8 @@ package components
 
 import (
 	"fmt"
-	"strings"
 
-	"github.com/go-errors/errors"
 	"github.com/jesseduffield/gocui"
-	"github.com/jesseduffield/lazygit/pkg/gui/context"
 )
 
 type Views struct {
@@ -30,95 +27,19 @@ func (self *Views) Secondary() *ViewDriver {
 }
 
 func (self *Views) regularView(viewName string) *ViewDriver {
-	return self.newStaticViewDriver(viewName, nil, nil, nil)
-}
-
-func (self *Views) patchExplorerViewByName(viewName string) *ViewDriver {
-	return self.newStaticViewDriver(
-		viewName,
-		func() ([]string, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.PatchExplorerContext)
-			state := ctx.GetState()
-			if state == nil {
-				return nil, errors.New("Expected patch explorer to be activated")
-			}
-			selectedContent := state.PlainRenderSelected()
-			// the above method returns a string with a trailing newline so we need to remove that before splitting
-			selectedLines := strings.Split(strings.TrimSuffix(selectedContent, "\n"), "\n")
-			return selectedLines, nil
-		},
-		func() (int, int, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.PatchExplorerContext)
-			state := ctx.GetState()
-			if state == nil {
-				return 0, 0, errors.New("Expected patch explorer to be activated")
-			}
-			startIdx, endIdx := state.SelectedRange()
-			return startIdx, endIdx, nil
-		},
-		func() (int, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.PatchExplorerContext)
-			state := ctx.GetState()
-			if state == nil {
-				return 0, errors.New("Expected patch explorer to be activated")
-			}
-			return state.GetSelectedLineIdx(), nil
-		},
-	)
-}
-
-// 'static' because it'll always refer to the same view, as opposed to the 'main' view which could actually be
-// one of several views, or the 'current' view which depends on focus.
-func (self *Views) newStaticViewDriver(
-	viewName string,
-	getSelectedLinesFn func() ([]string, error),
-	getSelectedLineRangeFn func() (int, int, error),
-	getSelectedLineIdxFn func() (int, error),
-) *ViewDriver {
 	return &ViewDriver{
-		context:              fmt.Sprintf("%s view", viewName),
-		getView:              func() *gocui.View { return self.t.gui.View(viewName) },
-		getSelectedLinesFn:   getSelectedLinesFn,
-		getSelectedRangeFn:   getSelectedLineRangeFn,
-		getSelectedLineIdxFn: getSelectedLineIdxFn,
-		t:                    self.t,
+		context: fmt.Sprintf("%s view", viewName),
+		getView: func() *gocui.View { return self.t.gui.View(viewName) },
+		t:       self.t,
 	}
 }
 
-func (self *Views) MergeConflicts() *ViewDriver {
-	viewName := "mergeConflicts"
-	return self.newStaticViewDriver(
-		viewName,
-		func() ([]string, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.MergeConflictsContext)
-			state := ctx.GetState()
-			if state == nil {
-				return nil, errors.New("Expected patch explorer to be activated")
-			}
-			selectedContent := strings.Split(state.PlainRenderSelected(), "\n")
+func (self *Views) patchExplorerViewByName(viewName string) *ViewDriver {
+	return self.regularView(viewName)
+}
 
-			return selectedContent, nil
-		},
-		func() (int, int, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.MergeConflictsContext)
-			state := ctx.GetState()
-			if state == nil {
-				return 0, 0, errors.New("Expected patch explorer to be activated")
-			}
-			startIdx, endIdx := state.GetSelectedRange()
-			return startIdx, endIdx, nil
-		},
-		// there is no concept of a cursor in the merge conflicts panel so we just return the start of the selection
-		func() (int, error) {
-			ctx := self.t.gui.ContextForView(viewName).(*context.MergeConflictsContext)
-			state := ctx.GetState()
-			if state == nil {
-				return 0, errors.New("Expected patch explorer to be activated")
-			}
-			startIdx, _ := state.GetSelectedRange()
-			return startIdx, nil
-		},
-	)
+func (self *Views) MergeConflicts() *ViewDriver {
+	return self.regularView("mergeConflicts")
 }
 
 func (self *Views) Commits() *ViewDriver {
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index a0e58d6fb..5b8e21e4b 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -260,6 +260,7 @@ var tests = []*components.IntegrationTest{
 	ui.DoublePopup,
 	ui.EmptyMenu,
 	ui.OpenLinkFailure,
+	ui.RangeSelect,
 	ui.SwitchTabFromMenu,
 	undo.UndoCheckoutAndDrop,
 	undo.UndoDrop,
diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go
new file mode 100644
index 000000000..84441701c
--- /dev/null
+++ b/pkg/integration/tests/ui/range_select.go
@@ -0,0 +1,142 @@
+package ui
+
+import (
+	"fmt"
+
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+// Here's the state machine we need to verify:
+// (no range, press 'v') -> sticky range
+// (no range, press arrow) -> no range
+// (no range, press shift+arrow) -> nonsticky range
+// (sticky range, press 'v') -> no range
+// (sticky range, press arrow) -> sticky range
+// (sticky range, press shift+arrow) -> nonsticky range
+// (nonsticky range, press 'v') -> no range
+// (nonsticky range, press arrow) -> no range
+// (nonsticky range, press shift+arrow) -> nonsticky range
+
+// Importantly, if you press 'v' when in a nonsticky range, it clears the range,
+// so no matter which mode you're in, 'v' will cancel the range.
+// And, if you press shift+up/down when in a sticky range, it switches to a non-
+// sticky range, meaning if you then press up/down without shift, it clears
+// the range.
+
+var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Verify range select works as expected in list views and in patch explorer views",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		// We're testing the commits view as our representative list context,
+		// as well as the staging view, and we're using the exact same code to test
+		// both to ensure they have the exact same behaviour (they are currently implemented
+		// separately)
+		// In both views we're going to have 10 lines starting from 'line 1' going down to
+		// 'line 10'.
+		fileContent := ""
+		total := 10
+		for i := 1; i <= total; i++ {
+			remaining := total - i + 1
+			// Commits are displayed in reverse order so to we need to create them in reverse to have them appear as 'line 1', 'line 2' etc.
+			shell.EmptyCommit(fmt.Sprintf("line %d", remaining))
+			fileContent = fmt.Sprintf("%sline %d\n", fileContent, i)
+		}
+		shell.CreateFile("file1", fileContent)
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		assertRangeSelectBehaviour := func(v *ViewDriver) {
+			v.
+				SelectedLines(
+					Contains("line 1"),
+				).
+				// (no range, press 'v') -> sticky range
+				Press(keys.Universal.ToggleRangeSelect).
+				SelectedLines(
+					Contains("line 1"),
+				).
+				// (sticky range, press arrow) -> sticky range
+				SelectNextItem().
+				SelectedLines(
+					Contains("line 1"),
+					Contains("line 2"),
+				).
+				// (sticky range, press 'v') -> no range
+				Press(keys.Universal.ToggleRangeSelect).
+				SelectedLines(
+					Contains("line 2"),
+				).
+				// (no range, press arrow) -> no range
+				SelectPreviousItem().
+				SelectedLines(
+					Contains("line 1"),
+				).
+				// (no range, press shift+arrow) -> nonsticky range
+				Press(keys.Universal.RangeSelectDown).
+				SelectedLines(
+					Contains("line 1"),
+					Contains("line 2"),
+				).
+				// (nonsticky range, press shift+arrow) -> nonsticky range
+				Press(keys.Universal.RangeSelectDown).
+				SelectedLines(
+					Contains("line 1"),
+					Contains("line 2"),
+					Contains("line 3"),
+				).
+				Press(keys.Universal.RangeSelectUp).
+				SelectedLines(
+					Contains("line 1"),
+					Contains("line 2"),
+				).
+				// (nonsticky range, press arrow) -> no range
+				SelectNextItem().
+				SelectedLines(
+					Contains("line 3"),
+				).
+				Press(keys.Universal.ToggleRangeSelect).
+				SelectedLines(
+					Contains("line 3"),
+				).
+				SelectNextItem().
+				SelectedLines(
+					Contains("line 3"),
+					Contains("line 4"),
+				).
+				// (sticky range, press shift+arrow) -> nonsticky range
+				Press(keys.Universal.RangeSelectDown).
+				SelectedLines(
+					Contains("line 3"),
+					Contains("line 4"),
+					Contains("line 5"),
+				).
+				SelectNextItem().
+				SelectedLines(
+					Contains("line 6"),
+				).
+				Press(keys.Universal.RangeSelectDown).
+				SelectedLines(
+					Contains("line 6"),
+					Contains("line 7"),
+				).
+				// (nonsticky range, press 'v') -> no range
+				Press(keys.Universal.ToggleRangeSelect).
+				SelectedLines(
+					Contains("line 7"),
+				)
+		}
+
+		assertRangeSelectBehaviour(t.Views().Commits().Focus())
+
+		t.Views().Files().
+			Focus().
+			SelectedLine(
+				Contains("file1"),
+			).
+			PressEnter()
+
+		assertRangeSelectBehaviour(t.Views().Staging().IsFocused())
+	},
+})
diff --git a/pkg/theme/theme.go b/pkg/theme/theme.go
index 20832f6d3..78be46fb6 100644
--- a/pkg/theme/theme.go
+++ b/pkg/theme/theme.go
@@ -30,9 +30,6 @@ var (
 	// SelectedLineBgColor is the background color for the selected line
 	SelectedLineBgColor = style.New()
 
-	// SelectedRangeBgColor is the background color of the selected range of lines
-	SelectedRangeBgColor = style.New()
-
 	// CherryPickedCommitColor is the text style when cherry picking a commit
 	CherryPickedCommitTextStyle = style.New()
 
@@ -52,7 +49,6 @@ func UpdateTheme(themeConfig config.ThemeConfig) {
 	InactiveBorderColor = GetGocuiStyle(themeConfig.InactiveBorderColor)
 	SearchingActiveBorderColor = GetGocuiStyle(themeConfig.SearchingActiveBorderColor)
 	SelectedLineBgColor = GetTextStyle(themeConfig.SelectedLineBgColor, true)
-	SelectedRangeBgColor = GetTextStyle(themeConfig.SelectedRangeBgColor, true)
 
 	cherryPickedCommitBgTextStyle := GetTextStyle(themeConfig.CherryPickedCommitBgColor, true)
 	cherryPickedCommitFgTextStyle := GetTextStyle(themeConfig.CherryPickedCommitFgColor, false)
diff --git a/schema/config.json b/schema/config.json
index 1239872ee..6d322ef3d 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -176,18 +176,6 @@
                 "blue"
               ]
             },
-            "selectedRangeBgColor": {
-              "items": {
-                "type": "string"
-              },
-              "type": "array",
-              "minItems": 1,
-              "uniqueItems": true,
-              "description": "Background color of selected range\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line",
-              "default": [
-                "blue"
-              ]
-            },
             "cherryPickedCommitFgColor": {
               "items": {
                 "type": "string"
@@ -667,6 +655,18 @@
               "type": "string",
               "default": "\u003e"
             },
+            "toggleRangeSelect": {
+              "type": "string",
+              "default": "v"
+            },
+            "rangeSelectDown": {
+              "type": "string",
+              "default": "\u003cs-down\u003e"
+            },
+            "rangeSelectUp": {
+              "type": "string",
+              "default": "\u003cs-up\u003e"
+            },
             "prevBlock": {
               "type": "string",
               "default": "\u003cleft\u003e"
diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml
index 4f481f0bc..5a822ae77 100644
--- a/test/default_test_config/config.yml
+++ b/test/default_test_config/config.yml
@@ -10,8 +10,6 @@ gui:
     - bold
     inactiveBorderColor:
     - black
-    SelectedRangeBgcolor:
-    - reverse
   # Not important in tests but it creates clutter in demos
   showRandomTip: false
   animateExplosion: false # takes too long

From d08fafb1c446572c40151e88f8025e28a62bd819 Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Sun, 14 Jan 2024 13:28:30 +1100
Subject: [PATCH 05/11] Clear range select upon pressing 'escape'

This is the highest priority of the escape actions because it's the thing you're
most likely to want to do upon hitting escape if you have a range selected.

Applying this to the staging/patch-building views is tricky: if we want this logic
for when a range of lines is selected, we'll also need to apply it when a hunk
is selected too. I still think it's worth it though: I've often accidentally
escaped from the staging view when trying to cancel a range selection.
---
 docs/dev/Codebase_Guide.md                    |  1 +
 .../controllers/patch_building_controller.go  |  8 ++++++
 pkg/gui/controllers/quit_actions.go           |  7 +++++
 pkg/gui/controllers/staging_controller.go     |  5 ++++
 .../patch_building/specific_selection.go      |  8 +++---
 pkg/integration/tests/ui/range_select.go      | 26 +++++++++++++++++++
 6 files changed, 50 insertions(+), 5 deletions(-)

diff --git a/docs/dev/Codebase_Guide.md b/docs/dev/Codebase_Guide.md
index 307482e2a..7f9752f5d 100644
--- a/docs/dev/Codebase_Guide.md
+++ b/docs/dev/Codebase_Guide.md
@@ -58,6 +58,7 @@
 * `pkg/gui/gui_common.go`: defines gui-specific methods that all controllers and helpers have access to
 * `pkg/i18n/english.go`: defines the set of i18n strings and their English values
 * `pkg/gui/controllers/helpers/refresh_helper.go`: manages refreshing of models. The refresh helper is typically invoked at the end of an action to re-load affected models from git (e.g. re-load branches after doing a git pull)
+* `pkg/gui/controllers/quit_actions.go`: contains code that runs when you hit 'escape' on a view (assuming the view doesn't define its own escape handler)
 * `vendor/github.com/jesseduffield/gocui/gui.go`: defines the gocui gui struct
 * `vendor/github.com/jesseduffield/gocui/view.go`: defines the gocui view struct
 
diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go
index 68179d158..ff5bd777b 100644
--- a/pkg/gui/controllers/patch_building_controller.go
+++ b/pkg/gui/controllers/patch_building_controller.go
@@ -154,5 +154,13 @@ func (self *PatchBuildingController) toggleSelection() error {
 }
 
 func (self *PatchBuildingController) Escape() error {
+	context := self.c.Contexts().CustomPatchBuilder
+	state := context.GetState()
+
+	if state.SelectingRange() || state.SelectingHunk() {
+		state.SetLineSelectMode()
+		return self.c.PostRefreshUpdate(context)
+	}
+
 	return self.c.Helpers().PatchBuilding.Escape()
 }
diff --git a/pkg/gui/controllers/quit_actions.go b/pkg/gui/controllers/quit_actions.go
index dd23448ec..7b1ba4c2d 100644
--- a/pkg/gui/controllers/quit_actions.go
+++ b/pkg/gui/controllers/quit_actions.go
@@ -51,6 +51,13 @@ func (self *QuitActions) confirmQuitDuringUpdate() error {
 func (self *QuitActions) Escape() error {
 	currentContext := self.c.CurrentContext()
 
+	if listContext, ok := currentContext.(types.IListContext); ok {
+		if listContext.GetList().IsSelectingRange() {
+			listContext.GetList().CancelRangeSelect()
+			return self.c.PostRefreshUpdate(listContext)
+		}
+	}
+
 	switch ctx := currentContext.(type) {
 	case types.IFilterableContext:
 		if ctx.IsFiltering() {
diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go
index 46cd6cb69..31432fa68 100644
--- a/pkg/gui/controllers/staging_controller.go
+++ b/pkg/gui/controllers/staging_controller.go
@@ -151,6 +151,11 @@ func (self *StagingController) EditFile() error {
 }
 
 func (self *StagingController) Escape() error {
+	if self.context.GetState().SelectingRange() || self.context.GetState().SelectingHunk() {
+		self.context.GetState().SetLineSelectMode()
+		return self.c.PostRefreshUpdate(self.context)
+	}
+
 	return self.c.PopContext()
 }
 
diff --git a/pkg/integration/tests/patch_building/specific_selection.go b/pkg/integration/tests/patch_building/specific_selection.go
index b9c9da8a6..a9dbf9f11 100644
--- a/pkg/integration/tests/patch_building/specific_selection.go
+++ b/pkg/integration/tests/patch_building/specific_selection.go
@@ -88,6 +88,9 @@ var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{
 						Contains(" 1f"),
 				)
 			}).
+			// Cancel hunk select
+			PressEscape().
+			// Escape the view
 			PressEscape()
 
 		t.Views().CommitFiles().
@@ -97,11 +100,6 @@ var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{
 
 		t.Views().PatchBuilding().
 			IsFocused().
-			// hunk is selected because selection mode persists across files
-			ContainsLines(
-				Contains("@@ -0,0 +1,26 @@").IsSelected(),
-			).
-			Press(keys.Main.ToggleSelectHunk).
 			SelectedLines(
 				Contains("+2a"),
 			).
diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go
index 84441701c..4885c7cb4 100644
--- a/pkg/integration/tests/ui/range_select.go
+++ b/pkg/integration/tests/ui/range_select.go
@@ -12,9 +12,11 @@ import (
 // (no range, press arrow) -> no range
 // (no range, press shift+arrow) -> nonsticky range
 // (sticky range, press 'v') -> no range
+// (sticky range, press 'escape') -> no range
 // (sticky range, press arrow) -> sticky range
 // (sticky range, press shift+arrow) -> nonsticky range
 // (nonsticky range, press 'v') -> no range
+// (nonsticky range, press 'escape') -> no range
 // (nonsticky range, press arrow) -> no range
 // (nonsticky range, press shift+arrow) -> nonsticky range
 
@@ -125,6 +127,30 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{
 				Press(keys.Universal.ToggleRangeSelect).
 				SelectedLines(
 					Contains("line 7"),
+				).
+				Press(keys.Universal.RangeSelectDown).
+				SelectedLines(
+					Contains("line 7"),
+					Contains("line 8"),
+				).
+				// (nonsticky range, press 'escape') -> no range
+				PressEscape().
+				SelectedLines(
+					Contains("line 8"),
+				).
+				Press(keys.Universal.ToggleRangeSelect).
+				SelectedLines(
+					Contains("line 8"),
+				).
+				SelectNextItem().
+				SelectedLines(
+					Contains("line 8"),
+					Contains("line 9"),
+				).
+				// (sticky range, press 'escape') -> no range
+				PressEscape().
+				SelectedLines(
+					Contains("line 9"),
 				)
 		}
 

From 8840c1a2b75749724b2dc6e329c2e150db93bc5a Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Sat, 13 Jan 2024 16:55:06 +1100
Subject: [PATCH 06/11] Remove 'v' menu keys

We can no longer use this because 'v' is globally reserved for range select.
Conveniently it was the first item in the menu anyway for both of these.
---
 pkg/gui/controllers/branches_controller.go             | 1 -
 pkg/gui/controllers/helpers/merge_and_rebase_helper.go | 1 -
 2 files changed, 2 deletions(-)

diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
index e0a38d463..866f3eae3 100644
--- a/pkg/gui/controllers/branches_controller.go
+++ b/pkg/gui/controllers/branches_controller.go
@@ -165,7 +165,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
 				ShowBranchHeads:         false,
 			})
 		},
-		Key: 'v',
 	}
 
 	unsetUpstreamItem := &types.MenuItem{
diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go
index de34f2193..2b94dfa7a 100644
--- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go
+++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go
@@ -169,7 +169,6 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
 				OnPress: func() error {
 					return self.c.PushContext(self.c.Contexts().Files)
 				},
-				Key: 'v',
 			},
 			{
 				Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),

From 54bd94ad24ca24ca12fab59e9dbf0d79fe7681da Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Sun, 14 Jan 2024 09:50:52 +1100
Subject: [PATCH 07/11] Add SetSelection function for list contexts and use it
 in most places

The only time we should call SetSelectedLineIdx is when we are happy for a
select range to be retained which means things like moving the selected line
index to top top/bottom or up/down a page as the user navigates.

But in every other case we should now call SetSelection because that will
set the selected index and cancel the range which is almost always what we
want.
---
 pkg/gui/context/commit_files_context.go           |  2 +-
 pkg/gui/context/filtered_list_view_model.go       |  2 +-
 pkg/gui/context/list_context_trait.go             |  2 +-
 pkg/gui/context/local_commits_context.go          |  2 +-
 pkg/gui/context/sub_commits_context.go            |  2 +-
 pkg/gui/context/suggestions_context.go            |  2 +-
 pkg/gui/context/traits/list_cursor.go             | 15 +++++++++++++++
 pkg/gui/context/working_tree_context.go           |  2 +-
 pkg/gui/controllers/bisect_controller.go          |  2 +-
 pkg/gui/controllers/branches_controller.go        |  6 +++---
 pkg/gui/controllers/filtering_menu_action.go      |  2 +-
 pkg/gui/controllers/helpers/fixup_helper.go       |  2 +-
 pkg/gui/controllers/helpers/refs_helper.go        | 14 +++++++-------
 pkg/gui/controllers/helpers/search_helper.go      |  4 ++--
 pkg/gui/controllers/helpers/sub_commits_helper.go |  2 +-
 pkg/gui/controllers/list_controller.go            |  2 +-
 pkg/gui/controllers/local_commits_controller.go   |  2 +-
 pkg/gui/controllers/remote_branches_controller.go |  2 +-
 pkg/gui/controllers/remotes_controller.go         |  2 +-
 pkg/gui/controllers/stash_controller.go           |  2 +-
 .../switch_to_diff_files_controller.go            |  2 +-
 pkg/gui/controllers/tags_controller.go            |  4 +++-
 pkg/gui/filetree/commit_file_tree_view_model.go   |  2 +-
 pkg/gui/filetree/file_tree_view_model.go          |  4 ++--
 pkg/gui/gui.go                                    |  2 +-
 pkg/gui/menu_panel.go                             |  2 +-
 pkg/gui/types/context.go                          |  2 ++
 27 files changed, 54 insertions(+), 35 deletions(-)

diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go
index 037554c91..ad1ffa031 100644
--- a/pkg/gui/context/commit_files_context.go
+++ b/pkg/gui/context/commit_files_context.go
@@ -63,7 +63,7 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
 	}
 
 	ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
-		ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
+		ctx.GetList().SetSelection(selectedLineIdx)
 		return ctx.HandleFocus(types.OnFocusOpts{})
 	}))
 
diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go
index 1e649550a..c8abbe4a1 100644
--- a/pkg/gui/context/filtered_list_view_model.go
+++ b/pkg/gui/context/filtered_list_view_model.go
@@ -31,5 +31,5 @@ func (self *FilteredListViewModel[T]) ClearFilter() {
 
 	self.FilteredList.ClearFilter()
 
-	self.SetSelectedLineIdx(unfilteredIndex)
+	self.SetSelection(unfilteredIndex)
 }
diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go
index c4f0e2549..d7315c9ec 100644
--- a/pkg/gui/context/list_context_trait.go
+++ b/pkg/gui/context/list_context_trait.go
@@ -102,7 +102,7 @@ func (self *ListContextTrait) HandleRender() error {
 }
 
 func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) error {
-	self.GetList().SetSelectedLineIdx(selectedLineIdx)
+	self.GetList().SetSelection(selectedLineIdx)
 	return self.HandleFocus(types.OnFocusOpts{})
 }
 
diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go
index e0172638d..61a40b30b 100644
--- a/pkg/gui/context/local_commits_context.go
+++ b/pkg/gui/context/local_commits_context.go
@@ -85,7 +85,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
 	}
 
 	ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
-		ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
+		ctx.GetList().SetSelection(selectedLineIdx)
 		return ctx.HandleFocus(types.OnFocusOpts{})
 	}))
 
diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go
index 79b0d9781..1f795b44d 100644
--- a/pkg/gui/context/sub_commits_context.go
+++ b/pkg/gui/context/sub_commits_context.go
@@ -134,7 +134,7 @@ func NewSubCommitsContext(
 	}
 
 	ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
-		ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
+		ctx.GetList().SetSelection(selectedLineIdx)
 		return ctx.HandleFocus(types.OnFocusOpts{})
 	}))
 
diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go
index e3c1f5f26..0921a7329 100644
--- a/pkg/gui/context/suggestions_context.go
+++ b/pkg/gui/context/suggestions_context.go
@@ -74,7 +74,7 @@ func (self *SuggestionsContext) GetSelectedItemId() string {
 
 func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) {
 	self.State.Suggestions = suggestions
-	self.SetSelectedLineIdx(0)
+	self.SetSelection(0)
 	self.c.ResetViewOrigin(self.GetView())
 	_ = self.HandleRender()
 }
diff --git a/pkg/gui/context/traits/list_cursor.go b/pkg/gui/context/traits/list_cursor.go
index 647f2a36b..85cf22e30 100644
--- a/pkg/gui/context/traits/list_cursor.go
+++ b/pkg/gui/context/traits/list_cursor.go
@@ -45,10 +45,25 @@ func (self *ListCursor) GetSelectedLineIdx() int {
 	return self.selectedIdx
 }
 
+// Sets the selected line index. Note, you probably don't want to use this directly,
+// because it doesn't affect the range select mode or range start index. You should only
+// use this for navigation situations where e.g. the user wants to jump to the top of
+// a list while in range select mode so that the selection ends up being between
+// the top of the list and the previous selection
 func (self *ListCursor) SetSelectedLineIdx(value int) {
 	self.selectedIdx = self.clampValue(value)
 }
 
+// Sets the selected index and cancels the range. You almost always want to use
+// this instead of SetSelectedLineIdx. For example, if you want to jump the cursor
+// to the top of a list after checking out a branch, you should use this method,
+// or you may end up with a large range selection from the previous cursor position
+// to the top of the list.
+func (self *ListCursor) SetSelection(value int) {
+	self.selectedIdx = self.clampValue(value)
+	self.CancelRangeSelect()
+}
+
 func (self *ListCursor) clampValue(value int) int {
 	clampedValue := -1
 	if self.list.Len() > 0 {
diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go
index 0e0b8d72b..72a991f76 100644
--- a/pkg/gui/context/working_tree_context.go
+++ b/pkg/gui/context/working_tree_context.go
@@ -50,7 +50,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
 	}
 
 	ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
-		ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
+		ctx.GetList().SetSelection(selectedLineIdx)
 		return ctx.HandleFocus(types.OnFocusOpts{})
 	}))
 
diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go
index 7cb36ef26..bff603afb 100644
--- a/pkg/gui/controllers/bisect_controller.go
+++ b/pkg/gui/controllers/bisect_controller.go
@@ -265,7 +265,7 @@ func (self *BisectController) selectCurrentBisectCommit() {
 		// find index of commit with that sha, move cursor to that.
 		for i, commit := range self.c.Model().Commits {
 			if commit.Sha == info.GetCurrentSha() {
-				self.context().SetSelectedLineIdx(i)
+				self.context().SetSelection(i)
 				_ = self.context().HandleFocus(types.OnFocusOpts{})
 				break
 			}
diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
index 866f3eae3..8e762d78b 100644
--- a/pkg/gui/controllers/branches_controller.go
+++ b/pkg/gui/controllers/branches_controller.go
@@ -424,7 +424,7 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
 		return self.c.Error(err)
 	}
 
-	self.context().SetSelectedLineIdx(0)
+	self.context().SetSelection(0)
 	return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
 }
 
@@ -627,7 +627,7 @@ func (self *BranchesController) createSortMenu() error {
 		if self.c.GetAppState().LocalBranchSortOrder != sortOrder {
 			self.c.GetAppState().LocalBranchSortOrder = sortOrder
 			self.c.SaveAppStateAndLogError()
-			self.c.Contexts().Branches.SetSelectedLineIdx(0)
+			self.c.Contexts().Branches.SetSelection(0)
 			return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
 		}
 		return nil
@@ -658,7 +658,7 @@ func (self *BranchesController) rename(branch *models.Branch) error {
 				// now that we've got our stuff again we need to find that branch and reselect it.
 				for i, newBranch := range self.c.Model().Branches {
 					if newBranch.Name == newBranchName {
-						self.context().SetSelectedLineIdx(i)
+						self.context().SetSelection(i)
 						if err := self.context().HandleRender(); err != nil {
 							return err
 						}
diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go
index 6a0f3b2db..d8525b99d 100644
--- a/pkg/gui/controllers/filtering_menu_action.go
+++ b/pkg/gui/controllers/filtering_menu_action.go
@@ -73,7 +73,7 @@ func (self *FilteringMenuAction) setFiltering(path string) error {
 	}
 
 	return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}, Then: func() {
-		self.c.Contexts().LocalCommits.SetSelectedLineIdx(0)
+		self.c.Contexts().LocalCommits.SetSelection(0)
 		self.c.Contexts().LocalCommits.FocusLine()
 	}})
 }
diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go
index 35c8233b8..0a1bc713e 100644
--- a/pkg/gui/controllers/helpers/fixup_helper.go
+++ b/pkg/gui/controllers/helpers/fixup_helper.go
@@ -87,7 +87,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
 			_ = self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
 		}
 
-		self.c.Contexts().LocalCommits.SetSelectedLineIdx(index)
+		self.c.Contexts().LocalCommits.SetSelection(index)
 		return self.c.PushContext(self.c.Contexts().LocalCommits)
 	}
 
diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go
index 6d0d64983..4e4461526 100644
--- a/pkg/gui/controllers/helpers/refs_helper.go
+++ b/pkg/gui/controllers/helpers/refs_helper.go
@@ -44,9 +44,9 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
 	cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars}
 
 	onSuccess := func() {
-		self.c.Contexts().Branches.SetSelectedLineIdx(0)
-		self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
-		self.c.Contexts().LocalCommits.SetSelectedLineIdx(0)
+		self.c.Contexts().Branches.SetSelection(0)
+		self.c.Contexts().ReflogCommits.SetSelection(0)
+		self.c.Contexts().LocalCommits.SetSelection(0)
 		// loading a heap of commits is slow so we limit them whenever doing a reset
 		self.c.Contexts().LocalCommits.SetLimitCommits(true)
 	}
@@ -107,8 +107,8 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
 		return self.c.Error(err)
 	}
 
-	self.c.Contexts().LocalCommits.SetSelectedLineIdx(0)
-	self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
+	self.c.Contexts().LocalCommits.SetSelection(0)
+	self.c.Contexts().ReflogCommits.SetSelection(0)
 	// loading a heap of commits is slow so we limit them whenever doing a reset
 	self.c.Contexts().LocalCommits.SetLimitCommits(true)
 
@@ -215,8 +215,8 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
 				}
 			}
 
-			self.c.Contexts().LocalCommits.SetSelectedLineIdx(0)
-			self.c.Contexts().Branches.SetSelectedLineIdx(0)
+			self.c.Contexts().LocalCommits.SetSelection(0)
+			self.c.Contexts().Branches.SetSelection(0)
 
 			return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
 		},
diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go
index 4c4b6918c..9ceea2f90 100644
--- a/pkg/gui/controllers/helpers/search_helper.go
+++ b/pkg/gui/controllers/helpers/search_helper.go
@@ -216,7 +216,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
 	state := self.searchState()
 	switch context := state.Context.(type) {
 	case types.IFilterableContext:
-		context.SetSelectedLineIdx(0)
+		context.SetSelection(0)
 		_ = context.GetView().SetOriginY(0)
 		context.SetFilter(searchString)
 		_ = self.c.PostRefreshUpdate(context)
@@ -232,7 +232,7 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) {
 	if context == state.Context {
 		filterableContext, ok := context.(types.IFilterableContext)
 		if ok {
-			filterableContext.SetSelectedLineIdx(0)
+			filterableContext.SetSelection(0)
 			_ = filterableContext.GetView().SetOriginY(0)
 			filterableContext.ReApplyFilter()
 		}
diff --git a/pkg/gui/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go
index 7f5417cc3..b572aa45b 100644
--- a/pkg/gui/controllers/helpers/sub_commits_helper.go
+++ b/pkg/gui/controllers/helpers/sub_commits_helper.go
@@ -53,7 +53,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
 	self.refreshHelper.RefreshAuthors(commits)
 
 	subCommitsContext := self.c.Contexts().SubCommits
-	subCommitsContext.SetSelectedLineIdx(0)
+	subCommitsContext.SetSelection(0)
 	subCommitsContext.SetParentContext(opts.Context)
 	subCommitsContext.SetWindowName(opts.Context.GetWindowName())
 	subCommitsContext.SetTitleRef(utils.TruncateWithEllipsis(opts.TitleRef, 50))
diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go
index 1f3c743bc..9b7a740b5 100644
--- a/pkg/gui/controllers/list_controller.go
+++ b/pkg/gui/controllers/list_controller.go
@@ -160,7 +160,7 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
 		return nil
 	}
 
-	self.context.GetList().SetSelectedLineIdx(newSelectedLineIdx)
+	self.context.GetList().SetSelection(newSelectedLineIdx)
 
 	if prevSelectedLineIdx == newSelectedLineIdx && alreadyFocused && self.context.GetOnClick() != nil {
 		return self.context.GetOnClick()()
diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go
index 97151f4fe..c484d2487 100644
--- a/pkg/gui/controllers/local_commits_controller.go
+++ b/pkg/gui/controllers/local_commits_controller.go
@@ -459,7 +459,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
 					return c.Sha == selectedCommit.Sha
 				})
 				if ok {
-					self.context().SetSelectedLineIdx(index)
+					self.context().SetSelection(index)
 				}
 			}})
 	})
diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go
index 04afd6415..d6f9e9036 100644
--- a/pkg/gui/controllers/remote_branches_controller.go
+++ b/pkg/gui/controllers/remote_branches_controller.go
@@ -132,7 +132,7 @@ func (self *RemoteBranchesController) createSortMenu() error {
 		if self.c.GetAppState().RemoteBranchSortOrder != sortOrder {
 			self.c.GetAppState().RemoteBranchSortOrder = sortOrder
 			self.c.SaveAppStateAndLogError()
-			self.c.Contexts().RemoteBranches.SetSelectedLineIdx(0)
+			self.c.Contexts().RemoteBranches.SetSelection(0)
 			return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}})
 		}
 		return nil
diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go
index d0f643eec..47f14417e 100644
--- a/pkg/gui/controllers/remotes_controller.go
+++ b/pkg/gui/controllers/remotes_controller.go
@@ -106,7 +106,7 @@ func (self *RemotesController) enter(remote *models.Remote) error {
 		newSelectedLine = -1
 	}
 	remoteBranchesContext := self.c.Contexts().RemoteBranches
-	remoteBranchesContext.SetSelectedLineIdx(newSelectedLine)
+	remoteBranchesContext.SetSelection(newSelectedLine)
 	remoteBranchesContext.SetTitleRef(remote.Name)
 	remoteBranchesContext.SetParentContext(self.Context())
 	remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix
diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go
index 5d74e10af..acd66cd31 100644
--- a/pkg/gui/controllers/stash_controller.go
+++ b/pkg/gui/controllers/stash_controller.go
@@ -189,7 +189,7 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr
 			if err != nil {
 				return err
 			}
-			self.context().SetSelectedLineIdx(0) // Select the renamed stash
+			self.context().SetSelection(0) // Select the renamed stash
 			self.context().FocusLine()
 			return nil
 		},
diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go
index 7143a8805..af2b38984 100644
--- a/pkg/gui/controllers/switch_to_diff_files_controller.go
+++ b/pkg/gui/controllers/switch_to_diff_files_controller.go
@@ -77,7 +77,7 @@ func (self *SwitchToDiffFilesController) Context() types.Context {
 func (self *SwitchToDiffFilesController) viewFiles(opts SwitchToCommitFilesContextOpts) error {
 	diffFilesContext := self.diffFilesContext
 
-	diffFilesContext.SetSelectedLineIdx(0)
+	diffFilesContext.SetSelection(0)
 	diffFilesContext.SetRef(opts.Ref)
 	diffFilesContext.SetTitleRef(opts.Ref.Description())
 	diffFilesContext.SetCanRebase(opts.CanRebase)
diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go
index dcbef4d2c..e96dde2e4 100644
--- a/pkg/gui/controllers/tags_controller.go
+++ b/pkg/gui/controllers/tags_controller.go
@@ -210,7 +210,9 @@ func (self *TagsController) createResetMenu(tag *models.Tag) error {
 
 func (self *TagsController) create() error {
 	// leaving commit SHA blank so that we're just creating the tag for the current commit
-	return self.c.Helpers().Tags.OpenCreateTagPrompt("", func() { self.context().SetSelectedLineIdx(0) })
+	return self.c.Helpers().Tags.OpenCreateTagPrompt("", func() {
+		self.context().SetSelection(0)
+	})
 }
 
 func (self *TagsController) withSelectedTag(f func(tag *models.Tag) error) func() error {
diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go
index a022bc25e..d7bc447a1 100644
--- a/pkg/gui/filetree/commit_file_tree_view_model.go
+++ b/pkg/gui/filetree/commit_file_tree_view_model.go
@@ -106,6 +106,6 @@ func (self *CommitFileTreeViewModel) ToggleShowTree() {
 
 	index, found := self.GetIndexForPath(path)
 	if found {
-		self.SetSelectedLineIdx(index)
+		self.SetSelection(index)
 	}
 }
diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go
index f19f74fbc..2364087d3 100644
--- a/pkg/gui/filetree/file_tree_view_model.go
+++ b/pkg/gui/filetree/file_tree_view_model.go
@@ -81,7 +81,7 @@ func (self *FileTreeViewModel) SetTree() {
 		newNodes := self.GetAllItems()
 		newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes)
 		if newIdx != -1 && newIdx != prevSelectedLineIdx {
-			self.SetSelectedLineIdx(newIdx)
+			self.SetSelection(newIdx)
 		}
 	}
 
@@ -128,7 +128,7 @@ func (self *FileTreeViewModel) findNewSelectedIdx(prevNodes []*FileNode, currNod
 
 func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) {
 	self.IFileTree.SetStatusFilter(filter)
-	self.IListCursor.SetSelectedLineIdx(0)
+	self.IListCursor.SetSelection(0)
 }
 
 // If we're going from flat to tree we want to select the same file.
diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go
index 6acdc804c..66ef453da 100644
--- a/pkg/gui/gui.go
+++ b/pkg/gui/gui.go
@@ -329,7 +329,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
 		// because e.g. with worktrees, we'll show the current worktree at the top of the list.
 		listContext, ok := contextToPush.(types.IListContext)
 		if ok {
-			listContext.GetList().SetSelectedLineIdx(0)
+			listContext.GetList().SetSelection(0)
 		}
 	}
 
diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go
index 88095584d..fe05e7e32 100644
--- a/pkg/gui/menu_panel.go
+++ b/pkg/gui/menu_panel.go
@@ -43,7 +43,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
 	}
 
 	gui.State.Contexts.Menu.SetMenuItems(opts.Items, opts.ColumnAlignment)
-	gui.State.Contexts.Menu.SetSelectedLineIdx(0)
+	gui.State.Contexts.Menu.SetSelection(0)
 
 	gui.Views.Menu.Title = opts.Title
 	gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
index b20d70beb..3557cf5aa 100644
--- a/pkg/gui/types/context.go
+++ b/pkg/gui/types/context.go
@@ -224,6 +224,7 @@ type IList interface {
 type IListCursor interface {
 	GetSelectedLineIdx() int
 	SetSelectedLineIdx(value int)
+	SetSelection(value int)
 	MoveSelectedLine(delta int)
 	ClampSelection()
 	CancelRangeSelect()
@@ -236,6 +237,7 @@ type IListCursor interface {
 
 type IListPanelState interface {
 	SetSelectedLineIdx(int)
+	SetSelection(int)
 	GetSelectedLineIdx() int
 }
 

From 280b4d60f893a0e20897091ab02617c32180b45d Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Sat, 13 Jan 2024 17:40:28 +1100
Subject: [PATCH 08/11] Support select range for cherry pick

This requires us to change the 'v' keybinding for paste to something else,
now that 'v' is used globally for toggling range select. So I'm using
'shift+v' and I'm likewise changing 'c' to 'shift+c' for copying, so
that they're consistent.

We will need to clearly communicate this change in keybindings.
---
 README.md                                     |  2 +-
 docs/Config.md                                |  5 +-
 docs/keybindings/Keybindings_en.md            | 11 +--
 docs/keybindings/Keybindings_ja.md            | 11 +--
 docs/keybindings/Keybindings_ko.md            | 11 +--
 docs/keybindings/Keybindings_nl.md            | 11 +--
 docs/keybindings/Keybindings_pl.md            | 11 +--
 docs/keybindings/Keybindings_ru.md            | 11 +--
 docs/keybindings/Keybindings_zh-CN.md         | 11 +--
 docs/keybindings/Keybindings_zh-TW.md         | 11 +--
 pkg/config/user_config.go                     |  6 +-
 .../controllers/basic_commits_controller.go   | 14 +--
 .../controllers/helpers/cherry_pick_helper.go | 30 ++++---
 pkg/i18n/chinese.go                           |  1 -
 pkg/i18n/dutch.go                             |  1 -
 pkg/i18n/english.go                           |  2 -
 pkg/i18n/japanese.go                          |  5 +-
 pkg/i18n/korean.go                            |  1 -
 pkg/i18n/polish.go                            |  1 -
 pkg/i18n/russian.go                           |  1 -
 pkg/i18n/traditional_chinese.go               |  1 -
 .../tests/cherry_pick/cherry_pick_range.go    | 85 +++++++++++++++++++
 pkg/integration/tests/test_list.go            |  1 +
 schema/config.json                            |  8 +-
 24 files changed, 148 insertions(+), 104 deletions(-)
 create mode 100644 pkg/integration/tests/cherry_pick/cherry_pick_range.go

diff --git a/README.md b/README.md
index ac45872e5..8aaf64850 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ You can also perform any these actions as a once-off (e.g. pressing `s` on a com
 
 ### Cherry-pick
 
-Press `c` on a commit to copy it and press `v` to paste (cherry-pick) it.
+Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it.
 
 ![cherry_pick](../assets/demo/cherry_pick-compressed.gif)
 
diff --git a/docs/Config.md b/docs/Config.md
index 966fce0f4..dde60382a 100644
--- a/docs/Config.md
+++ b/docs/Config.md
@@ -249,9 +249,8 @@ keybinding:
     amendToCommit: 'A'
     pickCommit: 'p' # pick commit (when mid-rebase)
     revertCommit: 't'
-    cherryPickCopy: 'c'
-    cherryPickCopyRange: 'C'
-    pasteCommits: 'v'
+    cherryPickCopy: 'C'
+    pasteCommits: 'V'
     tagCommit: 'T'
     checkoutCommit: ''
     resetCherryPick: ''
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index b96d22ec6..04db47490 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -88,7 +88,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: Squash all 'fixup!' commits above selected commit (autosquash)
   <c-j>: Move commit down one
   <c-k>: Move commit up one
-  v: Paste commits (cherry-pick)
+  V: Paste commits (cherry-pick)
   B: Mark commit as base commit for rebase
   A: Amend commit with staged changes
   a: Set/Reset commit author
@@ -101,8 +101,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: View reset options
-  c: Copy commit (cherry-pick)
-  C: Copy commit range (cherry-pick)
+  C: Copy commit (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
   /: Search the current view by text
@@ -248,8 +247,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: View reset options
-  c: Copy commit (cherry-pick)
-  C: Copy commit range (cherry-pick)
+  C: Copy commit (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: View commits
@@ -316,8 +314,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: View reset options
-  c: Copy commit (cherry-pick)
-  C: Copy commit range (cherry-pick)
+  C: Copy commit (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md
index 4365e3685..3f7f9fb01 100644
--- a/docs/keybindings/Keybindings_ja.md
+++ b/docs/keybindings/Keybindings_ja.md
@@ -70,8 +70,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: ブラウザでコミットを開く
   n: コミットにブランチを作成
   g: View reset options
-  c: コミットをコピー (cherry-pick)
-  C: コミットを範囲コピー (cherry-pick)
+  C: コミットをコピー (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
@@ -107,7 +106,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: Squash all 'fixup!' commits above selected commit (autosquash)
   <c-j>: コミットを1つ下に移動
   <c-k>: コミットを1つ上に移動
-  v: コミットを貼り付け (cherry-pick)
+  V: コミットを貼り付け (cherry-pick)
   B: Mark commit as base commit for rebase
   A: ステージされた変更でamendコミット
   a: Set/Reset commit author
@@ -120,8 +119,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: ブラウザでコミットを開く
   n: コミットにブランチを作成
   g: View reset options
-  c: コミットをコピー (cherry-pick)
-  C: コミットを範囲コピー (cherry-pick)
+  C: コミットをコピー (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
   /: 検索を開始
@@ -348,8 +346,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: ブラウザでコミットを開く
   n: コミットにブランチを作成
   g: View reset options
-  c: コミットをコピー (cherry-pick)
-  C: コミットを範囲コピー (cherry-pick)
+  C: コミットをコピー (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: コミットを閲覧
diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md
index 7d60e1b07..df5024617 100644
--- a/docs/keybindings/Keybindings_ko.md
+++ b/docs/keybindings/Keybindings_ko.md
@@ -57,8 +57,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 브라우저에서 커밋 열기
   n: 커밋에서 새 브랜치를 만듭니다.
   g: View reset options
-  c: 커밋을 복사 (cherry-pick)
-  C: 커밋을 범위로 복사 (cherry-pick)
+  C: 커밋을 복사 (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: 커밋 보기
@@ -88,8 +87,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 브라우저에서 커밋 열기
   n: 커밋에서 새 브랜치를 만듭니다.
   g: View reset options
-  c: 커밋을 복사 (cherry-pick)
-  C: 커밋을 범위로 복사 (cherry-pick)
+  C: 커밋을 복사 (cherry-pick)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
@@ -270,7 +268,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: Squash all 'fixup!' commits above selected commit (autosquash)
   <c-j>: 커밋을 1개 아래로 이동
   <c-k>: 커밋을 1개 위로 이동
-  v: 커밋을 붙여넣기 (cherry-pick)
+  V: 커밋을 붙여넣기 (cherry-pick)
   B: Mark commit as base commit for rebase
   A: Amend commit with staged changes
   a: Set/Reset commit author
@@ -283,8 +281,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 브라우저에서 커밋 열기
   n: 커밋에서 새 브랜치를 만듭니다.
   g: View reset options
-  c: 커밋을 복사 (cherry-pick)
-  C: 커밋을 범위로 복사 (cherry-pick)
+  C: 커밋을 복사 (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: View selected item's files
   /: 검색 시작
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index 1a1d432d5..1ffd1ddcc 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -151,7 +151,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: Squash bovenstaande commits
   <c-j>: Verplaats commit 1 naar beneden
   <c-k>: Verplaats commit 1 naar boven
-  v: Plak commits (cherry-pick)
+  V: Plak commits (cherry-pick)
   B: Mark commit as base commit for rebase
   A: Wijzig commit met staged veranderingen
   a: Set/Reset commit author
@@ -164,8 +164,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Creëer nieuwe branch van commit
   g: Bekijk reset opties
-  c: Kopieer commit (cherry-pick)
-  C: Kopieer commit reeks (cherry-pick)
+  C: Kopieer commit (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: Bekijk gecommite bestanden
   /: Start met zoeken
@@ -227,8 +226,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Creëer nieuwe branch van commit
   g: Bekijk reset opties
-  c: Kopieer commit (cherry-pick)
-  C: Kopieer commit reeks (cherry-pick)
+  C: Kopieer commit (cherry-pick)
   <c-r>: Reset cherry-picked (gekopieerde) commits selectie
   <c-t>: Open external diff tool (git difftool)
   <enter>: Bekijk commits
@@ -316,8 +314,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Creëer nieuwe branch van commit
   g: Bekijk reset opties
-  c: Kopieer commit (cherry-pick)
-  C: Kopieer commit reeks (cherry-pick)
+  C: Kopieer commit (cherry-pick)
   <c-r>: Reset cherry-picked (gekopieerde) commits selectie
   <c-t>: Open external diff tool (git difftool)
   <enter>: Bekijk gecommite bestanden
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index 3290d6e38..8b1061824 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -72,7 +72,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: Spłaszcz wszystkie commity naprawcze powyżej zaznaczonych commitów (autosquash)
   <c-j>: Przenieś commit 1 w dół
   <c-k>: Przenieś commit 1 w górę
-  v: Wklej commity (przebieranie)
+  V: Wklej commity (przebieranie)
   B: Mark commit as base commit for rebase
   A: Popraw commit zmianami z poczekalni
   a: Set/Reset commit author
@@ -85,8 +85,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: Wyświetl opcje resetu
-  c: Kopiuj commit (przebieranie)
-  C: Kopiuj zakres commitów (przebieranie)
+  C: Kopiuj commit (przebieranie)
   <c-t>: Open external diff tool (git difftool)
   <enter>: Przeglądaj pliki commita
   /: Search the current view by text
@@ -225,8 +224,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: Wyświetl opcje resetu
-  c: Kopiuj commit (przebieranie)
-  C: Kopiuj zakres commitów (przebieranie)
+  C: Kopiuj commit (przebieranie)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: View commits
@@ -309,8 +307,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: Open commit in browser
   n: Create new branch off of commit
   g: Wyświetl opcje resetu
-  c: Kopiuj commit (przebieranie)
-  C: Kopiuj zakres commitów (przebieranie)
+  C: Kopiuj commit (przebieranie)
   <c-r>: Reset cherry-picked (copied) commits selection
   <c-t>: Open external diff tool (git difftool)
   <enter>: Przeglądaj pliki commita
diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md
index 76cec5c5f..b2904d335 100644
--- a/docs/keybindings/Keybindings_ru.md
+++ b/docs/keybindings/Keybindings_ru.md
@@ -127,8 +127,7 @@ _Связки клавиш_
   o: Открыть коммит в браузере
   n: Создать новую ветку с этого коммита
   g: Просмотреть параметры сброса
-  c: Скопировать отобранные коммит (cherry-pick)
-  C: Скопировать несколько отобранных коммитов (cherry-pick)
+  C: Скопировать отобранные коммит (cherry-pick)
   <c-r>: Сбросить отобранную (скопированную | cherry-picked) выборку коммитов
   <c-t>: Open external diff tool (git difftool)
   <enter>: Просмотреть коммиты
@@ -153,7 +152,7 @@ _Связки клавиш_
   S: Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение)
   <c-j>: Переместить коммит вниз на один
   <c-k>: Переместить коммит вверх на один
-  v: Вставить отобранные коммиты (cherry-pick)
+  V: Вставить отобранные коммиты (cherry-pick)
   B: Mark commit as base commit for rebase
   A: Править последний коммит с проиндексированными изменениями
   a: Установить/убрать автора коммита
@@ -166,8 +165,7 @@ _Связки клавиш_
   o: Открыть коммит в браузере
   n: Создать новую ветку с этого коммита
   g: Просмотреть параметры сброса
-  c: Скопировать отобранные коммит (cherry-pick)
-  C: Скопировать несколько отобранных коммитов (cherry-pick)
+  C: Скопировать отобранные коммит (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: Просмотреть файлы выбранного элемента
   /: Найти
@@ -224,8 +222,7 @@ _Связки клавиш_
   o: Открыть коммит в браузере
   n: Создать новую ветку с этого коммита
   g: Просмотреть параметры сброса
-  c: Скопировать отобранные коммит (cherry-pick)
-  C: Скопировать несколько отобранных коммитов (cherry-pick)
+  C: Скопировать отобранные коммит (cherry-pick)
   <c-r>: Сбросить отобранную (скопированную | cherry-picked) выборку коммитов
   <c-t>: Open external diff tool (git difftool)
   <enter>: Просмотреть файлы выбранного элемента
diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md
index 333cfada5..1496f8624 100644
--- a/docs/keybindings/Keybindings_zh-CN.md
+++ b/docs/keybindings/Keybindings_zh-CN.md
@@ -57,8 +57,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 在浏览器中打开提交
   n: 从提交创建新分支
   g: 查看重置选项
-  c: 复制提交(拣选)
-  C: 复制提交范围(拣选)
+  C: 复制提交(拣选)
   <c-r>: 重置已拣选(复制)的提交
   <c-t>: Open external diff tool (git difftool)
   <enter>: 查看提交
@@ -112,8 +111,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 在浏览器中打开提交
   n: 从提交创建新分支
   g: 查看重置选项
-  c: 复制提交(拣选)
-  C: 复制提交范围(拣选)
+  C: 复制提交(拣选)
   <c-r>: 重置已拣选(复制)的提交
   <c-t>: Open external diff tool (git difftool)
   <enter>: 查看提交的文件
@@ -153,7 +151,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   S: 压缩在所选提交之上的所有“fixup!”提交(自动压缩)
   <c-j>: 下移提交
   <c-k>: 上移提交
-  v: 粘贴提交(拣选)
+  V: 粘贴提交(拣选)
   B: Mark commit as base commit for rebase
   A: 用已暂存的更改来修补提交
   a: Set/Reset commit author
@@ -166,8 +164,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   o: 在浏览器中打开提交
   n: 从提交创建新分支
   g: 查看重置选项
-  c: 复制提交(拣选)
-  C: 复制提交范围(拣选)
+  C: 复制提交(拣选)
   <c-t>: Open external diff tool (git difftool)
   <enter>: 查看提交的文件
   /: 开始搜索
diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md
index 81a2c4248..6243eafd4 100644
--- a/docs/keybindings/Keybindings_zh-TW.md
+++ b/docs/keybindings/Keybindings_zh-TW.md
@@ -57,8 +57,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   o: 在瀏覽器中開啟提交
   n: 從提交建立新分支
   g: 檢視重設選項
-  c: 複製提交 (揀選)
-  C: 複製提交範圍 (揀選)
+  C: 複製提交 (揀選)
   <c-r>: 重設選定的揀選 (複製) 提交
   <c-t>: Open external diff tool (git difftool)
   <enter>: 檢視提交
@@ -153,8 +152,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   o: 在瀏覽器中開啟提交
   n: 從提交建立新分支
   g: 檢視重設選項
-  c: 複製提交 (揀選)
-  C: 複製提交範圍 (揀選)
+  C: 複製提交 (揀選)
   <c-r>: 重設選定的揀選 (複製) 提交
   <c-t>: Open external diff tool (git difftool)
   <enter>: 檢視所選項目的檔案
@@ -194,7 +192,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   S: 壓縮上方所有的“fixup!”提交 (自動壓縮)
   <c-j>: 向下移動提交
   <c-k>: 向上移動提交
-  v: 貼上提交 (揀選)
+  V: 貼上提交 (揀選)
   B: Mark commit as base commit for rebase
   A: 使用已預存的更改修正提交
   a: 設置/重設提交作者
@@ -207,8 +205,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   o: 在瀏覽器中開啟提交
   n: 從提交建立新分支
   g: 檢視重設選項
-  c: 複製提交 (揀選)
-  C: 複製提交範圍 (揀選)
+  C: 複製提交 (揀選)
   <c-t>: Open external diff tool (git difftool)
   <enter>: 檢視所選項目的檔案
   /: 開始搜尋
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index a966905d0..e9f739a1d 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -418,7 +418,6 @@ type KeybindingCommitsConfig struct {
 	PickCommit                     string `yaml:"pickCommit"`
 	RevertCommit                   string `yaml:"revertCommit"`
 	CherryPickCopy                 string `yaml:"cherryPickCopy"`
-	CherryPickCopyRange            string `yaml:"cherryPickCopyRange"`
 	PasteCommits                   string `yaml:"pasteCommits"`
 	MarkCommitAsBaseForRebase      string `yaml:"markCommitAsBaseForRebase"`
 	CreateTag                      string `yaml:"tagCommit"`
@@ -812,9 +811,8 @@ func GetDefaultConfig() *UserConfig {
 				ResetCommitAuthor:              "a",
 				PickCommit:                     "p",
 				RevertCommit:                   "t",
-				CherryPickCopy:                 "c",
-				CherryPickCopyRange:            "C",
-				PasteCommits:                   "v",
+				CherryPickCopy:                 "C",
+				PasteCommits:                   "V",
 				MarkCommitAsBaseForRebase:      "B",
 				CreateTag:                      "T",
 				CheckoutCommit:                 "",
diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go
index 551349466..2f120a0f4 100644
--- a/pkg/gui/controllers/basic_commits_controller.go
+++ b/pkg/gui/controllers/basic_commits_controller.go
@@ -14,6 +14,7 @@ var _ types.IController = &BasicCommitsController{}
 
 type ContainsCommits interface {
 	types.Context
+	types.IListContext
 	GetSelected() *models.Commit
 	GetCommits() []*models.Commit
 	GetSelectedLineIdx() int
@@ -64,13 +65,8 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.CherryPickCopy),
-			Handler:     self.checkSelected(self.copy),
-			Description: self.c.Tr.CherryPickCopy,
-		},
-		{
-			Key:         opts.GetKey(opts.Config.Commits.CherryPickCopyRange),
 			Handler:     self.checkSelected(self.copyRange),
-			Description: self.c.Tr.CherryPickCopyRange,
+			Description: self.c.Tr.CherryPickCopy,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.ResetCherryPick),
@@ -271,12 +267,8 @@ func (self *BasicCommitsController) checkout(commit *models.Commit) error {
 	})
 }
 
-func (self *BasicCommitsController) copy(commit *models.Commit) error {
-	return self.c.Helpers().CherryPick.Copy(commit, self.context.GetCommits(), self.context)
-}
-
 func (self *BasicCommitsController) copyRange(*models.Commit) error {
-	return self.c.Helpers().CherryPick.CopyRange(self.context.GetSelectedLineIdx(), self.context.GetCommits(), self.context)
+	return self.c.Helpers().CherryPick.CopyRange(self.context.GetCommits(), self.context)
 }
 
 func (self *BasicCommitsController) openDiffTool(commit *models.Commit) error {
diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go
index 4f455ca30..61a37220b 100644
--- a/pkg/gui/controllers/helpers/cherry_pick_helper.go
+++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go
@@ -5,6 +5,7 @@ import (
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
+	"github.com/samber/lo"
 )
 
 type CherryPickHelper struct {
@@ -45,25 +46,30 @@ func (self *CherryPickHelper) Copy(commit *models.Commit, commitsList []*models.
 	return self.rerender()
 }
 
-func (self *CherryPickHelper) CopyRange(selectedIndex int, commitsList []*models.Commit, context types.Context) error {
+func (self *CherryPickHelper) CopyRange(commitsList []*models.Commit, context types.IListContext) error {
+	startIdx, endIdx := context.GetList().GetSelectionRange()
+
 	if err := self.resetIfNecessary(context); err != nil {
 		return err
 	}
 
 	commitSet := self.getData().SelectedShaSet()
 
-	// find the last commit that is copied that's above our position
-	// if there are none, startIndex = 0
-	startIndex := 0
-	for index, commit := range commitsList[0:selectedIndex] {
-		if commitSet.Includes(commit.Sha) {
-			startIndex = index
-		}
-	}
+	allCommitsCopied := lo.EveryBy(commitsList[startIdx:endIdx+1], func(commit *models.Commit) bool {
+		return commitSet.Includes(commit.Sha)
+	})
 
-	for index := startIndex; index <= selectedIndex; index++ {
-		commit := commitsList[index]
-		self.getData().Add(commit, commitsList)
+	// if all selected commits are already copied, we'll uncopy them
+	if allCommitsCopied {
+		for index := startIdx; index <= endIdx; index++ {
+			commit := commitsList[index]
+			self.getData().Remove(commit, commitsList)
+		}
+	} else {
+		for index := startIdx; index <= endIdx; index++ {
+			commit := commitsList[index]
+			self.getData().Add(commit, commitsList)
+		}
 	}
 
 	return self.rerender()
diff --git a/pkg/i18n/chinese.go b/pkg/i18n/chinese.go
index 0482f4046..8386bce1e 100644
--- a/pkg/i18n/chinese.go
+++ b/pkg/i18n/chinese.go
@@ -198,7 +198,6 @@ func chineseTranslationSet() TranslationSet {
 		YouAreHere:                          "您在这里",
 		RewordNotSupported:                  "当前不支持交互式重新基准化时的重新措词提交",
 		CherryPickCopy:                      "复制提交(拣选)",
-		CherryPickCopyRange:                 "复制提交范围(拣选)",
 		PasteCommits:                        "粘贴提交(拣选)",
 		SureCherryPick:                      "您确定要将选中的提交进行拣选到这个分支吗?",
 		CherryPick:                          "拣选 (Cherry-Pick)",
diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go
index 79207490b..1e2eaa689 100644
--- a/pkg/i18n/dutch.go
+++ b/pkg/i18n/dutch.go
@@ -163,7 +163,6 @@ func dutchTranslationSet() TranslationSet {
 		YouAreHere:                          "JE BENT HIER",
 		RewordNotSupported:                  "Herformatteren van commits in interactief rebasen is nog niet ondersteund",
 		CherryPickCopy:                      "Kopieer commit (cherry-pick)",
-		CherryPickCopyRange:                 "Kopieer commit reeks (cherry-pick)",
 		PasteCommits:                        "Plak commits (cherry-pick)",
 		SureCherryPick:                      "Weet je zeker dat je de gekopieerde commits naar deze branch wil cherry-picken?",
 		CherryPick:                          "Cherry-Pick",
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index ad859dc1d..e9d0c9a65 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -251,7 +251,6 @@ type TranslationSet struct {
 	RewordNotSupported                  string
 	ChangingThisActionIsNotAllowed      string
 	CherryPickCopy                      string
-	CherryPickCopyRange                 string
 	PasteCommits                        string
 	SureCherryPick                      string
 	CherryPick                          string
@@ -1090,7 +1089,6 @@ func EnglishTranslationSet() TranslationSet {
 		RewordNotSupported:                  "Rewording commits while interactively rebasing is not currently supported",
 		ChangingThisActionIsNotAllowed:      "Changing this kind of rebase todo entry is not allowed",
 		CherryPickCopy:                      "Copy commit (cherry-pick)",
-		CherryPickCopyRange:                 "Copy commit range (cherry-pick)",
 		PasteCommits:                        "Paste commits (cherry-pick)",
 		SureCherryPick:                      "Are you sure you want to cherry-pick the copied commits onto this branch?",
 		CherryPick:                          "Cherry-pick",
diff --git a/pkg/i18n/japanese.go b/pkg/i18n/japanese.go
index 660746384..3da17b097 100644
--- a/pkg/i18n/japanese.go
+++ b/pkg/i18n/japanese.go
@@ -201,9 +201,8 @@ func japaneseTranslationSet() TranslationSet {
 		// NoRoom:                              "Not enough room",
 		YouAreHere: "現在位置",
 		// LcRewordNotSupported:                "Rewording commits while interactively rebasing is not currently supported",
-		CherryPickCopy:      "コミットをコピー (cherry-pick)",
-		CherryPickCopyRange: "コミットを範囲コピー (cherry-pick)",
-		PasteCommits:        "コミットを貼り付け (cherry-pick)",
+		CherryPickCopy: "コミットをコピー (cherry-pick)",
+		PasteCommits:   "コミットを貼り付け (cherry-pick)",
 		// SureCherryPick:                      "Are you sure you want to cherry-pick the copied commits onto this branch?",
 		CherryPick:          "Cherry-Pick",
 		Donate:              "支援",
diff --git a/pkg/i18n/korean.go b/pkg/i18n/korean.go
index ad23090e7..3c4d0ceab 100644
--- a/pkg/i18n/korean.go
+++ b/pkg/i18n/korean.go
@@ -199,7 +199,6 @@ func koreanTranslationSet() TranslationSet {
 		YouAreHere:                          "현재 위치",
 		RewordNotSupported:                  "Rewording commits while interactively rebasing is not currently supported",
 		CherryPickCopy:                      "커밋을 복사 (cherry-pick)",
-		CherryPickCopyRange:                 "커밋을 범위로 복사 (cherry-pick)",
 		PasteCommits:                        "커밋을 붙여넣기 (cherry-pick)",
 		SureCherryPick:                      "정말로 복사한 커밋을 이 브랜치에 체리픽하시겠습니까?",
 		CherryPick:                          "체리픽",
diff --git a/pkg/i18n/polish.go b/pkg/i18n/polish.go
index ecab04281..e1515a948 100644
--- a/pkg/i18n/polish.go
+++ b/pkg/i18n/polish.go
@@ -131,7 +131,6 @@ func polishTranslationSet() TranslationSet {
 		YouAreHere:                          "JESTEŚ TU",
 		RewordNotSupported:                  "Przeredagowanie commitów podczas interaktywnej zmiany bazy nie jest obecnie wspierane",
 		CherryPickCopy:                      "Kopiuj commit (przebieranie)",
-		CherryPickCopyRange:                 "Kopiuj zakres commitów (przebieranie)",
 		PasteCommits:                        "Wklej commity (przebieranie)",
 		SureCherryPick:                      "Czy na pewno chcesz przebierać w skopiowanych commitach na tej gałęzi?",
 		CherryPick:                          "Przebieranie",
diff --git a/pkg/i18n/russian.go b/pkg/i18n/russian.go
index 387e34974..1522a0f1c 100644
--- a/pkg/i18n/russian.go
+++ b/pkg/i18n/russian.go
@@ -243,7 +243,6 @@ func RussianTranslationSet() TranslationSet {
 		RewordNotSupported:                  "Переформулировка коммитов при интерактивном перебазировании в настоящее время не поддерживается",
 		ChangingThisActionIsNotAllowed:      "Изменение этого типа записи todo перебазирования не допускается",
 		CherryPickCopy:                      "Скопировать отобранные коммит (cherry-pick)",
-		CherryPickCopyRange:                 "Скопировать несколько отобранных коммитов (cherry-pick)",
 		PasteCommits:                        "Вставить отобранные коммиты (cherry-pick)",
 		SureCherryPick:                      "Вы уверены, что хотите выборочно применить (cherry-picked) отобранные коммиты в эту ветку?",
 		CherryPick:                          "Выборочная отборка (Cherry-Pick)",
diff --git a/pkg/i18n/traditional_chinese.go b/pkg/i18n/traditional_chinese.go
index 12207edcf..b9519bfcd 100644
--- a/pkg/i18n/traditional_chinese.go
+++ b/pkg/i18n/traditional_chinese.go
@@ -274,7 +274,6 @@ func traditionalChineseTranslationSet() TranslationSet {
 		RewordNotSupported:                  "在互動變基期間改寫提交目前不支持",
 		ChangingThisActionIsNotAllowed:      "不允許更改此類變基待辦事項",
 		CherryPickCopy:                      "複製提交 (揀選)",
-		CherryPickCopyRange:                 "複製提交範圍 (揀選)",
 		PasteCommits:                        "貼上提交 (揀選)",
 		SureCherryPick:                      "你確定要將複製的提交揀選到此分支嗎?",
 		CherryPick:                          "揀選 (Cherry-pick)",
diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_range.go b/pkg/integration/tests/cherry_pick/cherry_pick_range.go
new file mode 100644
index 000000000..99b29618f
--- /dev/null
+++ b/pkg/integration/tests/cherry_pick/cherry_pick_range.go
@@ -0,0 +1,85 @@
+package cherry_pick
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var CherryPickRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Cherry pick range of commits from the subcommits view, without conflicts",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.
+			EmptyCommit("base").
+			NewBranch("first-branch").
+			NewBranch("second-branch").
+			Checkout("first-branch").
+			EmptyCommit("one").
+			EmptyCommit("two").
+			Checkout("second-branch").
+			EmptyCommit("three").
+			EmptyCommit("four").
+			Checkout("first-branch")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Branches().
+			Focus().
+			Lines(
+				Contains("first-branch"),
+				Contains("second-branch"),
+				Contains("master"),
+			).
+			SelectNextItem().
+			PressEnter()
+
+		t.Views().SubCommits().
+			IsFocused().
+			Lines(
+				Contains("four").IsSelected(),
+				Contains("three"),
+				Contains("base"),
+			).
+			// copy commits 'four' and 'three'
+			Press(keys.Universal.RangeSelectDown).
+			Lines(
+				Contains("four").IsSelected(),
+				Contains("three").IsSelected(),
+				Contains("base"),
+			).
+			Press(keys.Commits.CherryPickCopy)
+
+		t.Views().Information().Content(Contains("2 commits copied"))
+
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("two").IsSelected(),
+				Contains("one"),
+				Contains("base"),
+			).
+			Press(keys.Commits.PasteCommits).
+			Tap(func() {
+				t.ExpectPopup().Alert().
+					Title(Equals("Cherry-pick")).
+					Content(Contains("Are you sure you want to cherry-pick the copied commits onto this branch?")).
+					Confirm()
+			}).
+			Lines(
+				Contains("four"),
+				Contains("three"),
+				Contains("two"),
+				Contains("one"),
+				Contains("base"),
+			).
+			Tap(func() {
+				// we need to manually exit out of cherry pick mode
+				t.Views().Information().Content(Contains("2 commits copied"))
+			}).
+			PressEscape().
+			Tap(func() {
+				t.Views().Information().Content(DoesNotContain("commits copied"))
+			})
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 5b8e21e4b..8541f9a5c 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -61,6 +61,7 @@ var tests = []*components.IntegrationTest{
 	cherry_pick.CherryPick,
 	cherry_pick.CherryPickConflicts,
 	cherry_pick.CherryPickDuringRebase,
+	cherry_pick.CherryPickRange,
 	commit.AddCoAuthor,
 	commit.Amend,
 	commit.Commit,
diff --git a/schema/config.json b/schema/config.json
index 6d322ef3d..d5131c0ff 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -1101,16 +1101,12 @@
               "default": "t"
             },
             "cherryPickCopy": {
-              "type": "string",
-              "default": "c"
-            },
-            "cherryPickCopyRange": {
               "type": "string",
               "default": "C"
             },
             "pasteCommits": {
               "type": "string",
-              "default": "v"
+              "default": "V"
             },
             "markCommitAsBaseForRebase": {
               "type": "string",
@@ -1515,4 +1511,4 @@
   },
   "additionalProperties": false,
   "type": "object"
-}
+}
\ No newline at end of file

From 51fb82d6bf26153f294477883a1b2abd592441f9 Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Sun, 14 Jan 2024 13:51:20 +1100
Subject: [PATCH 09/11] Enforce single-item selection in various actions

We want to show an error when the user tries to invoke an action that expects only
a single item to be selected.

We're using the GetDisabledReason field to enforce this (as well as DisabledReason
on menu items).

I've created a ListControllerTrait to store some shared convenience functions for this.
---
 pkg/gui/context/traits/list_cursor.go         |   8 +
 .../controllers/basic_commits_controller.go   |  73 +++---
 pkg/gui/controllers/bisect_controller.go      |  57 ++--
 pkg/gui/controllers/branches_controller.go    | 154 +++++------
 pkg/gui/controllers/command_log_controller.go |   4 +-
 .../commit_description_controller.go          |   4 +-
 .../controllers/commit_message_controller.go  |   4 +-
 .../controllers/commits_files_controller.go   |  76 +++---
 .../controllers/confirmation_controller.go    |   4 +-
 .../controllers/context_lines_controller.go   |   4 +-
 .../custom_patch_options_menu_action.go       |  13 +-
 pkg/gui/controllers/files_controller.go       | 103 ++++----
 .../controllers/files_remove_controller.go    |  39 +--
 pkg/gui/controllers/git_flow_controller.go    |  33 +--
 pkg/gui/controllers/global_controller.go      |   4 +-
 .../jump_to_side_window_controller.go         |   4 +-
 pkg/gui/controllers/list_controller_trait.go  |  95 +++++++
 .../controllers/local_commits_controller.go   | 243 ++++++++----------
 pkg/gui/controllers/menu_controller.go        |  34 +--
 .../controllers/merge_conflicts_controller.go |   4 +-
 .../controllers/patch_building_controller.go  |   4 +-
 .../controllers/reflog_commits_controller.go  |  11 +-
 .../controllers/remote_branches_controller.go |  74 +++---
 pkg/gui/controllers/remotes_controller.go     |  55 ++--
 .../controllers/search_prompt_controller.go   |   4 +-
 pkg/gui/controllers/side_window_controller.go |   8 +-
 pkg/gui/controllers/snake_controller.go       |   4 +-
 pkg/gui/controllers/staging_controller.go     |   4 +-
 pkg/gui/controllers/stash_controller.go       |  60 ++---
 pkg/gui/controllers/status_controller.go      |   4 +-
 pkg/gui/controllers/sub_commits_controller.go |  11 +-
 pkg/gui/controllers/submodules_controller.go  |  69 +++--
 pkg/gui/controllers/suggestions_controller.go |  19 +-
 .../switch_to_diff_files_controller.go        |  36 ++-
 .../switch_to_sub_commits_controller.go       |  27 +-
 pkg/gui/controllers/tags_controller.go        |  57 ++--
 pkg/gui/controllers/undo_controller.go        |   4 +-
 .../worktree_options_controller.go            |  29 +--
 pkg/gui/controllers/worktrees_controller.go   |  55 ++--
 pkg/gui/global_handlers.go                    |  22 ++
 pkg/gui/keybindings.go                        |  72 +++---
 pkg/gui/types/context.go                      |   1 +
 pkg/i18n/english.go                           |   8 +-
 pkg/integration/tests/file/copy_menu.go       |   4 +-
 pkg/integration/tests/ui/empty_menu.go        |   9 +-
 45 files changed, 854 insertions(+), 757 deletions(-)
 create mode 100644 pkg/gui/controllers/list_controller_trait.go

diff --git a/pkg/gui/context/traits/list_cursor.go b/pkg/gui/context/traits/list_cursor.go
index 85cf22e30..368485c05 100644
--- a/pkg/gui/context/traits/list_cursor.go
+++ b/pkg/gui/context/traits/list_cursor.go
@@ -114,10 +114,18 @@ func (self *ListCursor) CancelRangeSelect() {
 	self.rangeSelectMode = RangeSelectModeNone
 }
 
+// Returns true if we are in range select mode. Note that we may be in range select
+// mode and still only selecting a single item. See AreMultipleItemsSelected below.
 func (self *ListCursor) IsSelectingRange() bool {
 	return self.rangeSelectMode != RangeSelectModeNone
 }
 
+// Returns true if we are in range select mode and selecting multiple items
+func (self *ListCursor) AreMultipleItemsSelected() bool {
+	startIdx, endIdx := self.GetSelectionRange()
+	return startIdx != endIdx
+}
+
 func (self *ListCursor) GetSelectionRange() (int, int) {
 	if self.IsSelectingRange() {
 		return utils.MinMax(self.selectedIdx, self.rangeStartIdx)
diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go
index 2f120a0f4..386877b4d 100644
--- a/pkg/gui/controllers/basic_commits_controller.go
+++ b/pkg/gui/controllers/basic_commits_controller.go
@@ -22,50 +22,61 @@ type ContainsCommits interface {
 
 type BasicCommitsController struct {
 	baseController
+	*ListControllerTrait[*models.Commit]
 	c       *ControllerCommon
 	context ContainsCommits
 }
 
-func NewBasicCommitsController(controllerCommon *ControllerCommon, context ContainsCommits) *BasicCommitsController {
+func NewBasicCommitsController(c *ControllerCommon, context ContainsCommits) *BasicCommitsController {
 	return &BasicCommitsController{
 		baseController: baseController{},
-		c:              controllerCommon,
+		c:              c,
 		context:        context,
+		ListControllerTrait: NewListControllerTrait[*models.Commit](
+			c,
+			context,
+			context.GetSelected,
+		),
 	}
 }
 
 func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Commits.CheckoutCommit),
-			Handler:     self.checkSelected(self.checkout),
-			Description: self.c.Tr.CheckoutCommit,
+			Key:               opts.GetKey(opts.Config.Commits.CheckoutCommit),
+			Handler:           self.withItem(self.checkout),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CheckoutCommit,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.CopyCommitAttributeToClipboard),
-			Handler:     self.checkSelected(self.copyCommitAttribute),
-			Description: self.c.Tr.CopyCommitAttributeToClipboard,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Commits.CopyCommitAttributeToClipboard),
+			Handler:           self.withItem(self.copyCommitAttribute),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CopyCommitAttributeToClipboard,
+			OpensMenu:         true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.OpenInBrowser),
-			Handler:     self.checkSelected(self.openInBrowser),
-			Description: self.c.Tr.OpenCommitInBrowser,
+			Key:               opts.GetKey(opts.Config.Commits.OpenInBrowser),
+			Handler:           self.withItem(self.openInBrowser),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenCommitInBrowser,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.New),
-			Handler:     self.checkSelected(self.newBranch),
-			Description: self.c.Tr.CreateNewBranchFromCommit,
+			Key:               opts.GetKey(opts.Config.Universal.New),
+			Handler:           self.withItem(self.newBranch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CreateNewBranchFromCommit,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
-			Handler:     self.checkSelected(self.createResetMenu),
-			Description: self.c.Tr.ViewResetOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Commits.ViewResetOptions),
+			Handler:           self.withItem(self.createResetMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewResetOptions,
+			OpensMenu:         true,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.CherryPickCopy),
-			Handler:     self.checkSelected(self.copyRange),
+			Handler:     self.withItem(self.copyRange),
 			Description: self.c.Tr.CherryPickCopy,
 		},
 		{
@@ -74,30 +85,16 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 			Description: self.c.Tr.ResetCherryPick,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenDiffTool),
-			Handler:     self.checkSelected(self.openDiffTool),
-			Description: self.c.Tr.OpenDiffTool,
+			Key:               opts.GetKey(opts.Config.Universal.OpenDiffTool),
+			Handler:           self.withItem(self.openDiffTool),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenDiffTool,
 		},
 	}
 
 	return bindings
 }
 
-func (self *BasicCommitsController) checkSelected(callback func(*models.Commit) error) func() error {
-	return func() error {
-		commit := self.context.GetSelected()
-		if commit == nil {
-			return nil
-		}
-
-		return callback(commit)
-	}
-}
-
-func (self *BasicCommitsController) Context() types.Context {
-	return self.context
-}
-
 func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) error {
 	return self.c.Menu(types.CreateMenuOptions{
 		Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go
index bff603afb..deb4f1b7a 100644
--- a/pkg/gui/controllers/bisect_controller.go
+++ b/pkg/gui/controllers/bisect_controller.go
@@ -14,17 +14,23 @@ import (
 
 type BisectController struct {
 	baseController
+	*ListControllerTrait[*models.Commit]
 	c *ControllerCommon
 }
 
 var _ types.IController = &BisectController{}
 
 func NewBisectController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *BisectController {
 	return &BisectController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
+		ListControllerTrait: NewListControllerTrait[*models.Commit](
+			c,
+			c.Contexts().LocalCommits,
+			c.Contexts().LocalCommits.GetSelected,
+		),
 	}
 }
 
@@ -32,7 +38,7 @@ func (self *BisectController) GetKeybindings(opts types.KeybindingsOpts) []*type
 	bindings := []*types.Binding{
 		{
 			Key:         opts.GetKey(opts.Config.Commits.ViewBisectOptions),
-			Handler:     opts.Guards.OutsideFilterMode(self.checkSelected(self.openMenu)),
+			Handler:     opts.Guards.OutsideFilterMode(self.withItem(self.openMenu)),
 			Description: self.c.Tr.ViewBisectOptions,
 			OpensMenu:   true,
 		},
@@ -70,9 +76,19 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c
 	// If we have a current sha already, then we always want to use that one. If
 	// not, we're still picking the initial commits before we really start, so
 	// use the selected commit in that case.
-	shaToMark := lo.Ternary(info.GetCurrentSha() != "", info.GetCurrentSha(), commit.Sha)
+
+	bisecting := info.GetCurrentSha() != ""
+	shaToMark := lo.Ternary(bisecting, info.GetCurrentSha(), commit.Sha)
 	shortShaToMark := utils.ShortSha(shaToMark)
 
+	// For marking a commit as bad, when we're not already bisecting, we require
+	// a single item selected, but once we are bisecting, it doesn't matter because
+	// the action applies to the HEAD commit rather than the selected commit.
+	var singleItemIfNotBisecting *types.DisabledReason
+	if !bisecting {
+		singleItemIfNotBisecting = self.require(self.singleItemSelected())()
+	}
+
 	menuItems := []*types.MenuItem{
 		{
 			Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortShaToMark, info.NewTerm()),
@@ -84,7 +100,8 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c
 
 				return self.afterMark(selectCurrentAfter, waitToReselect)
 			},
-			Key: 'b',
+			DisabledReason: singleItemIfNotBisecting,
+			Key:            'b',
 		},
 		{
 			Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortShaToMark, info.OldTerm()),
@@ -96,7 +113,8 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c
 
 				return self.afterMark(selectCurrentAfter, waitToReselect)
 			},
-			Key: 'g',
+			DisabledReason: singleItemIfNotBisecting,
+			Key:            'g',
 		},
 		{
 			Label: fmt.Sprintf(self.c.Tr.Bisect.SkipCurrent, shortShaToMark),
@@ -108,7 +126,8 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c
 
 				return self.afterMark(selectCurrentAfter, waitToReselect)
 			},
-			Key: 's',
+			DisabledReason: singleItemIfNotBisecting,
+			Key:            's',
 		},
 	}
 	if info.GetCurrentSha() != "" && info.GetCurrentSha() != commit.Sha {
@@ -122,7 +141,8 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c
 
 				return self.afterMark(selectCurrentAfter, waitToReselect)
 			},
-			Key: 'S',
+			DisabledReason: self.require(self.singleItemSelected())(),
+			Key:            'S',
 		}))
 	}
 	menuItems = append(menuItems, lo.ToPtr(types.MenuItem{
@@ -157,7 +177,8 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo,
 
 					return self.c.Helpers().Bisect.PostBisectCommandRefresh()
 				},
-				Key: 'b',
+				DisabledReason: self.require(self.singleItemSelected())(),
+				Key:            'b',
 			},
 			{
 				Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
@@ -173,7 +194,8 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo,
 
 					return self.c.Helpers().Bisect.PostBisectCommandRefresh()
 				},
-				Key: 'g',
+				DisabledReason: self.require(self.singleItemSelected())(),
+				Key:            'g',
 			},
 			{
 				Label: self.c.Tr.Bisect.ChooseTerms,
@@ -273,21 +295,6 @@ func (self *BisectController) selectCurrentBisectCommit() {
 	}
 }
 
-func (self *BisectController) checkSelected(callback func(*models.Commit) error) func() error {
-	return func() error {
-		commit := self.context().GetSelected()
-		if commit == nil {
-			return nil
-		}
-
-		return callback(commit)
-	}
-}
-
-func (self *BisectController) Context() types.Context {
-	return self.context()
-}
-
 func (self *BisectController) context() *context.LocalCommitsContext {
 	return self.c.Contexts().LocalCommits
 }
diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
index 8e762d78b..37a637202 100644
--- a/pkg/gui/controllers/branches_controller.go
+++ b/pkg/gui/controllers/branches_controller.go
@@ -17,48 +17,61 @@ import (
 
 type BranchesController struct {
 	baseController
+	*ListControllerTrait[*models.Branch]
 	c *ControllerCommon
 }
 
 var _ types.IController = &BranchesController{}
 
 func NewBranchesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *BranchesController {
 	return &BranchesController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
+		ListControllerTrait: NewListControllerTrait[*models.Branch](
+			c,
+			c.Contexts().Branches,
+			c.Contexts().Branches.GetSelected,
+		),
 	}
 }
 
 func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	return []*types.Binding{
 		{
-			Key:               opts.GetKey(opts.Config.Universal.Select),
-			Handler:           self.checkSelected(self.press),
-			GetDisabledReason: self.getDisabledReasonForPress,
-			Description:       self.c.Tr.Checkout,
+			Key:     opts.GetKey(opts.Config.Universal.Select),
+			Handler: self.withItem(self.press),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(),
+				self.notPulling,
+			),
+			Description: self.c.Tr.Checkout,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.New),
-			Handler:     self.checkSelected(self.newBranch),
-			Description: self.c.Tr.NewBranch,
+			Key:               opts.GetKey(opts.Config.Universal.New),
+			Handler:           self.withItem(self.newBranch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.NewBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.CreatePullRequest),
-			Handler:     self.checkSelected(self.handleCreatePullRequest),
-			Description: self.c.Tr.CreatePullRequest,
+			Key:               opts.GetKey(opts.Config.Branches.CreatePullRequest),
+			Handler:           self.withItem(self.handleCreatePullRequest),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CreatePullRequest,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.ViewPullRequestOptions),
-			Handler:     self.checkSelected(self.handleCreatePullRequestMenu),
-			Description: self.c.Tr.CreatePullRequestOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Branches.ViewPullRequestOptions),
+			Handler:           self.withItem(self.handleCreatePullRequestMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CreatePullRequestOptions,
+			OpensMenu:         true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.CopyPullRequestURL),
-			Handler:     self.copyPullRequestURL,
-			Description: self.c.Tr.CopyPullRequestURL,
+			Key:               opts.GetKey(opts.Config.Branches.CopyPullRequestURL),
+			Handler:           self.copyPullRequestURL,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CopyPullRequestURL,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Branches.CheckoutBranchByName),
@@ -66,60 +79,69 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
 			Description: self.c.Tr.CheckoutByName,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.ForceCheckoutBranch),
-			Handler:     self.forceCheckout,
-			Description: self.c.Tr.ForceCheckout,
+			Key:               opts.GetKey(opts.Config.Branches.ForceCheckoutBranch),
+			Handler:           self.forceCheckout,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ForceCheckout,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelectedAndReal(self.delete),
-			Description: self.c.Tr.ViewDeleteOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.delete),
+			GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
+			Description:       self.c.Tr.ViewDeleteOptions,
+			OpensMenu:         true,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Branches.RebaseBranch),
-			Handler:           opts.Guards.OutsideFilterMode(self.rebase),
-			Description:       self.c.Tr.RebaseBranch,
-			GetDisabledReason: self.getDisabledReasonForRebase,
+			Key:     opts.GetKey(opts.Config.Branches.RebaseBranch),
+			Handler: opts.Guards.OutsideFilterMode(self.rebase),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.notRebasingOntoSelf),
+			),
+			Description: self.c.Tr.RebaseBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
-			Handler:     opts.Guards.OutsideFilterMode(self.merge),
-			Description: self.c.Tr.MergeIntoCurrentBranch,
+			Key:               opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
+			Handler:           opts.Guards.OutsideFilterMode(self.merge),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.MergeIntoCurrentBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.FastForward),
-			Handler:     self.checkSelectedAndReal(self.fastForward),
-			Description: self.c.Tr.FastForward,
+			Key:               opts.GetKey(opts.Config.Branches.FastForward),
+			Handler:           self.withItem(self.fastForward),
+			GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
+			Description:       self.c.Tr.FastForward,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.CreateTag),
-			Handler:     self.checkSelected(self.createTag),
-			Description: self.c.Tr.CreateTag,
+			Key:               opts.GetKey(opts.Config.Branches.CreateTag),
+			Handler:           self.withItem(self.createTag),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CreateTag,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Branches.SortOrder),
 			Handler:     self.createSortMenu,
 			Description: self.c.Tr.SortOrder,
-			OpensMenu:   true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
-			Handler:     self.checkSelected(self.createResetMenu),
-			Description: self.c.Tr.ViewResetOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Commits.ViewResetOptions),
+			Handler:           self.withItem(self.createResetMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewResetOptions,
+			OpensMenu:         true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.RenameBranch),
-			Handler:     self.checkSelectedAndReal(self.rename),
-			Description: self.c.Tr.RenameBranch,
+			Key:               opts.GetKey(opts.Config.Branches.RenameBranch),
+			Handler:           self.withItem(self.rename),
+			GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
+			Description:       self.c.Tr.RenameBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.SetUpstream),
-			Handler:     self.checkSelected(self.viewUpstreamOptions),
-			Description: self.c.Tr.ViewBranchUpstreamOptions,
-			Tooltip:     self.c.Tr.ViewBranchUpstreamOptionsTooltip,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Branches.SetUpstream),
+			Handler:           self.withItem(self.viewUpstreamOptions),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewBranchUpstreamOptions,
+			Tooltip:           self.c.Tr.ViewBranchUpstreamOptionsTooltip,
+			OpensMenu:         true,
 		},
 	}
 }
@@ -308,7 +330,7 @@ func (self *BranchesController) press(selectedBranch *models.Branch) error {
 	return self.c.Helpers().Refs.CheckoutRef(selectedBranch.Name, types.CheckoutRefOptions{})
 }
 
-func (self *BranchesController) getDisabledReasonForPress() *types.DisabledReason {
+func (self *BranchesController) notPulling() *types.DisabledReason {
 	currentBranch := self.c.Helpers().Refs.GetCheckedOutRef()
 	if currentBranch != nil {
 		op := self.c.State().GetItemOperation(currentBranch)
@@ -561,8 +583,8 @@ func (self *BranchesController) rebase() error {
 	return self.c.Helpers().MergeAndRebase.RebaseOntoRef(selectedBranchName)
 }
 
-func (self *BranchesController) getDisabledReasonForRebase() *types.DisabledReason {
-	selectedBranchName := self.context().GetSelected().Name
+func (self *BranchesController) notRebasingOntoSelf(branch *models.Branch) *types.DisabledReason {
+	selectedBranchName := branch.Name
 	checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef().Name
 	if selectedBranchName == checkedOutBranch {
 		return &types.DisabledReason{Text: self.c.Tr.CantRebaseOntoSelf}
@@ -753,24 +775,10 @@ func (self *BranchesController) createPullRequest(from string, to string) error
 	return nil
 }
 
-func (self *BranchesController) checkSelected(callback func(*models.Branch) error) func() error {
-	return func() error {
-		selectedItem := self.context().GetSelected()
-		if selectedItem == nil {
-			return nil
-		}
-
-		return callback(selectedItem)
+func (self *BranchesController) branchIsReal(branch *models.Branch) *types.DisabledReason {
+	if !branch.IsRealBranch() {
+		return &types.DisabledReason{Text: self.c.Tr.SelectedItemIsNotABranch}
 	}
-}
 
-func (self *BranchesController) checkSelectedAndReal(callback func(*models.Branch) error) func() error {
-	return func() error {
-		selectedItem := self.context().GetSelected()
-		if selectedItem == nil || !selectedItem.IsRealBranch() {
-			return nil
-		}
-
-		return callback(selectedItem)
-	}
+	return nil
 }
diff --git a/pkg/gui/controllers/command_log_controller.go b/pkg/gui/controllers/command_log_controller.go
index 0c3479914..92b6540be 100644
--- a/pkg/gui/controllers/command_log_controller.go
+++ b/pkg/gui/controllers/command_log_controller.go
@@ -12,11 +12,11 @@ type CommandLogController struct {
 var _ types.IController = &CommandLogController{}
 
 func NewCommandLogController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *CommandLogController {
 	return &CommandLogController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go
index 13bb5949f..8f07cecfc 100644
--- a/pkg/gui/controllers/commit_description_controller.go
+++ b/pkg/gui/controllers/commit_description_controller.go
@@ -13,11 +13,11 @@ type CommitDescriptionController struct {
 var _ types.IController = &CommitMessageController{}
 
 func NewCommitDescriptionController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *CommitDescriptionController {
 	return &CommitDescriptionController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go
index fc5aca970..c52a8038f 100644
--- a/pkg/gui/controllers/commit_message_controller.go
+++ b/pkg/gui/controllers/commit_message_controller.go
@@ -14,11 +14,11 @@ type CommitMessageController struct {
 var _ types.IController = &CommitMessageController{}
 
 func NewCommitMessageController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *CommitMessageController {
 	return &CommitMessageController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go
index 5b3097363..a5333e448 100644
--- a/pkg/gui/controllers/commits_files_controller.go
+++ b/pkg/gui/controllers/commits_files_controller.go
@@ -12,61 +12,74 @@ import (
 
 type CommitFilesController struct {
 	baseController
+	*ListControllerTrait[*filetree.CommitFileNode]
 	c *ControllerCommon
 }
 
 var _ types.IController = &CommitFilesController{}
 
 func NewCommitFilesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *CommitFilesController {
 	return &CommitFilesController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
+		ListControllerTrait: NewListControllerTrait[*filetree.CommitFileNode](
+			c,
+			c.Contexts().CommitFiles,
+			c.Contexts().CommitFiles.GetSelected,
+		),
 	}
 }
 
 func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile),
-			Handler:     self.checkSelected(self.checkout),
-			Description: self.c.Tr.CheckoutCommitFile,
+			Key:               opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile),
+			Handler:           self.withItem(self.checkout),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.CheckoutCommitFile,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.discard),
-			Description: self.c.Tr.DiscardOldFileChange,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.discard),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.DiscardOldFileChange,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenFile),
-			Handler:     self.checkSelected(self.open),
-			Description: self.c.Tr.OpenFile,
+			Key:               opts.GetKey(opts.Config.Universal.OpenFile),
+			Handler:           self.withItem(self.open),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenFile,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Edit),
-			Handler:     self.checkSelected(self.edit),
-			Description: self.c.Tr.EditFile,
+			Key:               opts.GetKey(opts.Config.Universal.Edit),
+			Handler:           self.withItem(self.edit),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EditFile,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenDiffTool),
-			Handler:     self.checkSelected(self.openDiffTool),
-			Description: self.c.Tr.OpenDiffTool,
+			Key:               opts.GetKey(opts.Config.Universal.OpenDiffTool),
+			Handler:           self.withItem(self.openDiffTool),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenDiffTool,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.checkSelected(self.toggleForPatch),
-			Description: self.c.Tr.ToggleAddToPatch,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.toggleForPatch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ToggleAddToPatch,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.ToggleStagedAll),
-			Handler:     self.checkSelected(self.toggleAllForPatch),
+			Handler:     self.withItem(self.toggleAllForPatch),
 			Description: self.c.Tr.ToggleAllInPatch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.GoInto),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.EnterFile,
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EnterFile,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.ToggleTreeView),
@@ -89,21 +102,6 @@ func (self *CommitFilesController) GetMouseKeybindings(opts types.KeybindingsOpt
 	}
 }
 
-func (self *CommitFilesController) checkSelected(callback func(*filetree.CommitFileNode) error) func() error {
-	return func() error {
-		selected := self.context().GetSelected()
-		if selected == nil {
-			return nil
-		}
-
-		return callback(selected)
-	}
-}
-
-func (self *CommitFilesController) Context() types.Context {
-	return self.context()
-}
-
 func (self *CommitFilesController) context() *context.CommitFilesContext {
 	return self.c.Contexts().CommitFiles
 }
diff --git a/pkg/gui/controllers/confirmation_controller.go b/pkg/gui/controllers/confirmation_controller.go
index 59ddf8c43..164af19ec 100644
--- a/pkg/gui/controllers/confirmation_controller.go
+++ b/pkg/gui/controllers/confirmation_controller.go
@@ -13,11 +13,11 @@ type ConfirmationController struct {
 var _ types.IController = &ConfirmationController{}
 
 func NewConfirmationController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *ConfirmationController {
 	return &ConfirmationController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/context_lines_controller.go b/pkg/gui/controllers/context_lines_controller.go
index d3ff7688d..ddb507b31 100644
--- a/pkg/gui/controllers/context_lines_controller.go
+++ b/pkg/gui/controllers/context_lines_controller.go
@@ -31,11 +31,11 @@ type ContextLinesController struct {
 var _ types.IController = &ContextLinesController{}
 
 func NewContextLinesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *ContextLinesController {
 	return &ContextLinesController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go
index a8feed168..5710b44b7 100644
--- a/pkg/gui/controllers/custom_patch_options_menu_action.go
+++ b/pkg/gui/controllers/custom_patch_options_menu_action.go
@@ -62,15 +62,22 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
 		if self.c.CurrentContext().GetKey() == self.c.Contexts().LocalCommits.GetKey() {
 			selectedCommit := self.c.Contexts().LocalCommits.GetSelected()
 			if selectedCommit != nil && self.c.Git().Patch.PatchBuilder.To != selectedCommit.Sha {
+
+				var disabledReason *types.DisabledReason
+				if self.c.Contexts().LocalCommits.AreMultipleItemsSelected() {
+					disabledReason = &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupported}
+				}
+
 				// adding this option to index 1
 				menuItems = append(
 					menuItems[:1],
 					append(
 						[]*types.MenuItem{
 							{
-								Label:   fmt.Sprintf(self.c.Tr.MovePatchToSelectedCommit, selectedCommit.Sha),
-								OnPress: self.handleMovePatchToSelectedCommit,
-								Key:     'm',
+								Label:          fmt.Sprintf(self.c.Tr.MovePatchToSelectedCommit, selectedCommit.Sha),
+								OnPress:        self.handleMovePatchToSelectedCommit,
+								Key:            'm',
+								DisabledReason: disabledReason,
 							},
 						}, menuItems[1:]...,
 					)...,
diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go
index 7fb21dda8..3d418bf8e 100644
--- a/pkg/gui/controllers/files_controller.go
+++ b/pkg/gui/controllers/files_controller.go
@@ -13,25 +13,32 @@ import (
 
 type FilesController struct {
 	baseController // nolint: unused
-	c              *ControllerCommon
+	*ListControllerTrait[*filetree.FileNode]
+	c *ControllerCommon
 }
 
 var _ types.IController = &FilesController{}
 
 func NewFilesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *FilesController {
 	return &FilesController{
-		c: common,
+		c: c,
+		ListControllerTrait: NewListControllerTrait[*filetree.FileNode](
+			c,
+			c.Contexts().Files,
+			c.Contexts().Files.GetSelected,
+		),
 	}
 }
 
 func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	return []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.checkSelectedFileNode(self.press),
-			Description: self.c.Tr.ToggleStaged,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.press),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ToggleStaged,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.OpenStatusFilter),
@@ -71,20 +78,23 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
 			Tooltip:     self.c.Tr.FindBaseCommitForFixupTooltip,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Edit),
-			Handler:     self.checkSelectedFileNode(self.edit),
-			Description: self.c.Tr.EditFile,
+			Key:               opts.GetKey(opts.Config.Universal.Edit),
+			Handler:           self.withItem(self.edit),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EditFile,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenFile),
-			Handler:     self.Open,
-			Description: self.c.Tr.OpenFile,
+			Key:               opts.GetKey(opts.Config.Universal.OpenFile),
+			Handler:           self.Open,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenFile,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Files.IgnoreFile),
-			Handler:     self.checkSelectedFileNode(self.ignoreOrExcludeMenu),
-			Description: self.c.Tr.Actions.IgnoreExcludeFile,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Files.IgnoreFile),
+			Handler:           self.withItem(self.ignoreOrExcludeMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Actions.IgnoreExcludeFile,
+			OpensMenu:         true,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.RefreshFiles),
@@ -108,9 +118,10 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
 			Description: self.c.Tr.ToggleStagedAll,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.GoInto),
-			Handler:     self.enter,
-			Description: self.c.Tr.FileEnter,
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Handler:           self.enter,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.FileEnter,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
@@ -130,9 +141,10 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
 			Description: self.c.Tr.ToggleTreeView,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenDiffTool),
-			Handler:     self.checkSelectedFileNode(self.openDiffTool),
-			Description: self.c.Tr.OpenDiffTool,
+			Key:               opts.GetKey(opts.Config.Universal.OpenDiffTool),
+			Handler:           self.withItem(self.openDiffTool),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenDiffTool,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Files.OpenMergeTool),
@@ -254,7 +266,7 @@ func (self *FilesController) GetOnRenderToMain() func() error {
 }
 
 func (self *FilesController) GetOnClick() func() error {
-	return self.checkSelectedFileNode(self.press)
+	return self.withItemGraceful(self.press)
 }
 
 // if we are dealing with a status for which there is no key in this map,
@@ -411,17 +423,6 @@ func (self *FilesController) press(node *filetree.FileNode) error {
 	return self.context().HandleFocus(types.OnFocusOpts{})
 }
 
-func (self *FilesController) checkSelectedFileNode(callback func(*filetree.FileNode) error) func() error {
-	return func() error {
-		node := self.context().GetSelected()
-		if node == nil {
-			return nil
-		}
-
-		return callback(node)
-	}
-}
-
 func (self *FilesController) Context() types.Context {
 	return self.context()
 }
@@ -798,7 +799,8 @@ func (self *FilesController) openCopyMenu() error {
 			self.c.Toast(self.c.Tr.FileNameCopiedToast)
 			return nil
 		},
-		Key: 'n',
+		DisabledReason: self.require(self.singleItemSelected())(),
+		Key:            'n',
 	}
 	copyPathItem := &types.MenuItem{
 		Label: self.c.Tr.CopyFilePath,
@@ -809,7 +811,8 @@ func (self *FilesController) openCopyMenu() error {
 			self.c.Toast(self.c.Tr.FilePathCopiedToast)
 			return nil
 		},
-		Key: 'p',
+		DisabledReason: self.require(self.singleItemSelected())(),
+		Key:            'p',
 	}
 	copyFileDiffItem := &types.MenuItem{
 		Label:   self.c.Tr.CopySelectedDiff,
@@ -827,6 +830,14 @@ func (self *FilesController) openCopyMenu() error {
 			self.c.Toast(self.c.Tr.FileDiffCopiedToast)
 			return nil
 		},
+		DisabledReason: self.require(self.singleItemSelected(
+			func(file *filetree.FileNode) *types.DisabledReason {
+				if !node.GetHasStagedOrTrackedChanges() {
+					return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
+				}
+				return nil
+			},
+		))(),
 		Key: 's',
 	}
 	copyAllDiff := &types.MenuItem{
@@ -844,21 +855,17 @@ func (self *FilesController) openCopyMenu() error {
 			self.c.Toast(self.c.Tr.AllFilesDiffCopiedToast)
 			return nil
 		},
+		DisabledReason: self.require(
+			func() *types.DisabledReason {
+				if !self.anyStagedOrTrackedFile() {
+					return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
+				}
+				return nil
+			},
+		)(),
 		Key: 'a',
 	}
 
-	if node == nil {
-		copyNameItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
-		copyPathItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
-		copyFileDiffItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
-	}
-	if node != nil && !node.GetHasStagedOrTrackedChanges() {
-		copyFileDiffItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
-	}
-	if !self.anyStagedOrTrackedFile() {
-		copyAllDiff.DisabledReason = &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError}
-	}
-
 	return self.c.Menu(types.CreateMenuOptions{
 		Title: self.c.Tr.CopyToClipboardMenu,
 		Items: []*types.MenuItem{
diff --git a/pkg/gui/controllers/files_remove_controller.go b/pkg/gui/controllers/files_remove_controller.go
index 2afa6e5a8..9b21557de 100644
--- a/pkg/gui/controllers/files_remove_controller.go
+++ b/pkg/gui/controllers/files_remove_controller.go
@@ -3,7 +3,6 @@ package controllers
 import (
 	"github.com/jesseduffield/gocui"
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
-	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/filetree"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/jesseduffield/lazygit/pkg/utils"
@@ -13,27 +12,34 @@ import (
 
 type FilesRemoveController struct {
 	baseController
+	*ListControllerTrait[*filetree.FileNode]
 	c *ControllerCommon
 }
 
 var _ types.IController = &FilesRemoveController{}
 
 func NewFilesRemoveController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *FilesRemoveController {
 	return &FilesRemoveController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
+		ListControllerTrait: NewListControllerTrait[*filetree.FileNode](
+			c,
+			c.Contexts().Files,
+			c.Contexts().Files.GetSelected,
+		),
 	}
 }
 
 func (self *FilesRemoveController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelectedFileNode(self.remove),
-			Description: self.c.Tr.ViewDiscardOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.remove),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewDiscardOptions,
+			OpensMenu:         true,
 		},
 	}
 
@@ -166,22 +172,3 @@ func (self *FilesRemoveController) ResetSubmodule(submodule *models.SubmoduleCon
 		return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
 	})
 }
-
-func (self *FilesRemoveController) checkSelectedFileNode(callback func(*filetree.FileNode) error) func() error {
-	return func() error {
-		node := self.context().GetSelected()
-		if node == nil {
-			return nil
-		}
-
-		return callback(node)
-	}
-}
-
-func (self *FilesRemoveController) Context() types.Context {
-	return self.context()
-}
-
-func (self *FilesRemoveController) context() *context.WorkingTreeContext {
-	return self.c.Contexts().Files
-}
diff --git a/pkg/gui/controllers/git_flow_controller.go b/pkg/gui/controllers/git_flow_controller.go
index 4086a142b..c8da4bd0c 100644
--- a/pkg/gui/controllers/git_flow_controller.go
+++ b/pkg/gui/controllers/git_flow_controller.go
@@ -4,24 +4,29 @@ import (
 	"fmt"
 
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
-	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/jesseduffield/lazygit/pkg/utils"
 )
 
 type GitFlowController struct {
 	baseController
+	*ListControllerTrait[*models.Branch]
 	c *ControllerCommon
 }
 
 var _ types.IController = &GitFlowController{}
 
 func NewGitFlowController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *GitFlowController {
 	return &GitFlowController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.Branch](
+			c,
+			c.Contexts().Branches,
+			c.Contexts().Branches.GetSelected,
+		),
+		c: c,
 	}
 }
 
@@ -29,7 +34,7 @@ func (self *GitFlowController) GetKeybindings(opts types.KeybindingsOpts) []*typ
 	bindings := []*types.Binding{
 		{
 			Key:         opts.GetKey(opts.Config.Branches.ViewGitFlowOptions),
-			Handler:     self.checkSelected(self.handleCreateGitFlowMenu),
+			Handler:     self.withItem(self.handleCreateGitFlowMenu),
 			Description: self.c.Tr.GitFlowOptions,
 			OpensMenu:   true,
 		},
@@ -68,6 +73,7 @@ func (self *GitFlowController) handleCreateGitFlowMenu(branch *models.Branch) er
 				OnPress: func() error {
 					return self.gitFlowFinishBranch(branch.Name)
 				},
+				DisabledReason: self.require(self.singleItemSelected())(),
 			},
 			{
 				Label:   "start feature",
@@ -102,22 +108,3 @@ func (self *GitFlowController) gitFlowFinishBranch(branchName string) error {
 	self.c.LogAction(self.c.Tr.Actions.GitFlowFinish)
 	return self.c.RunSubprocessAndRefresh(cmdObj)
 }
-
-func (self *GitFlowController) checkSelected(callback func(*models.Branch) error) func() error {
-	return func() error {
-		node := self.context().GetSelected()
-		if node == nil {
-			return nil
-		}
-
-		return callback(node)
-	}
-}
-
-func (self *GitFlowController) Context() types.Context {
-	return self.context()
-}
-
-func (self *GitFlowController) context() *context.BranchesContext {
-	return self.c.Contexts().Branches
-}
diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go
index 2942567e8..f8e9b3e6b 100644
--- a/pkg/gui/controllers/global_controller.go
+++ b/pkg/gui/controllers/global_controller.go
@@ -11,11 +11,11 @@ type GlobalController struct {
 }
 
 func NewGlobalController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *GlobalController {
 	return &GlobalController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go
index 7ac407ab4..a3985968f 100644
--- a/pkg/gui/controllers/jump_to_side_window_controller.go
+++ b/pkg/gui/controllers/jump_to_side_window_controller.go
@@ -14,11 +14,11 @@ type JumpToSideWindowController struct {
 }
 
 func NewJumpToSideWindowController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *JumpToSideWindowController {
 	return &JumpToSideWindowController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/list_controller_trait.go b/pkg/gui/controllers/list_controller_trait.go
new file mode 100644
index 000000000..fa60223b9
--- /dev/null
+++ b/pkg/gui/controllers/list_controller_trait.go
@@ -0,0 +1,95 @@
+package controllers
+
+import "github.com/jesseduffield/lazygit/pkg/gui/types"
+
+// Embed this into your list controller to get some convenience methods for
+// ensuring a single item is selected, etc.
+
+type ListControllerTrait[T comparable] struct {
+	c           *ControllerCommon
+	context     types.IListContext
+	getSelected func() T
+}
+
+func NewListControllerTrait[T comparable](
+	c *ControllerCommon,
+	context types.IListContext,
+	getSelected func() T,
+) *ListControllerTrait[T] {
+	return &ListControllerTrait[T]{
+		c:           c,
+		context:     context,
+		getSelected: getSelected,
+	}
+}
+
+// Convenience function for combining multiple disabledReason callbacks.
+// The first callback to return a disabled reason will be the one returned.
+func (self *ListControllerTrait[T]) require(callbacks ...func() *types.DisabledReason) func() *types.DisabledReason {
+	return func() *types.DisabledReason {
+		for _, callback := range callbacks {
+			if disabledReason := callback(); disabledReason != nil {
+				return disabledReason
+			}
+		}
+
+		return nil
+	}
+}
+
+// Convenience function for enforcing that a single item is selected.
+// Also takes callbacks for additional disabled reasons, and passes the selected
+// item into each one.
+func (self *ListControllerTrait[T]) singleItemSelected(callbacks ...func(T) *types.DisabledReason) func() *types.DisabledReason {
+	return func() *types.DisabledReason {
+		if self.context.GetList().AreMultipleItemsSelected() {
+			return &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupported}
+		}
+
+		var zeroValue T
+		item := self.getSelected()
+		if item == zeroValue {
+			return &types.DisabledReason{Text: self.c.Tr.NoItemSelected}
+		}
+
+		for _, callback := range callbacks {
+			if reason := callback(item); reason != nil {
+				return reason
+			}
+		}
+
+		return nil
+	}
+}
+
+// Passes the selected item to the callback. Used for handler functions.
+func (self *ListControllerTrait[T]) withItem(callback func(T) error) func() error {
+	return func() error {
+		var zeroValue T
+		commit := self.getSelected()
+		if commit == zeroValue {
+			return self.c.ErrorMsg(self.c.Tr.NoItemSelected)
+		}
+
+		return callback(commit)
+	}
+}
+
+// Like withItem, but doesn't show an error message if no item is selected.
+// Use this for click actions (it's a no-op to click empty space)
+func (self *ListControllerTrait[T]) withItemGraceful(callback func(T) error) func() error {
+	return func() error {
+		var zeroValue T
+		commit := self.getSelected()
+		if commit == zeroValue {
+			return nil
+		}
+
+		return callback(commit)
+	}
+}
+
+// All controllers must implement this method so we're defining it here for convenience
+func (self *ListControllerTrait[T]) Context() types.Context {
+	return self.context
+}
diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go
index c484d2487..5fe08b85e 100644
--- a/pkg/gui/controllers/local_commits_controller.go
+++ b/pkg/gui/controllers/local_commits_controller.go
@@ -25,6 +25,7 @@ type (
 
 type LocalCommitsController struct {
 	baseController
+	*ListControllerTrait[*models.Commit]
 	c *ControllerCommon
 
 	pullFiles PullFilesFn
@@ -33,13 +34,18 @@ type LocalCommitsController struct {
 var _ types.IController = &LocalCommitsController{}
 
 func NewLocalCommitsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 	pullFiles PullFilesFn,
 ) *LocalCommitsController {
 	return &LocalCommitsController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 		pullFiles:      pullFiles,
+		ListControllerTrait: NewListControllerTrait[*models.Commit](
+			c,
+			c.Contexts().LocalCommits,
+			c.Contexts().LocalCommits.GetSelected,
+		),
 	}
 }
 
@@ -48,47 +54,59 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 
 	outsideFilterModeBindings := []*types.Binding{
 		{
-			Key:               opts.GetKey(opts.Config.Commits.SquashDown),
-			Handler:           self.checkSelected(self.squashDown),
-			GetDisabledReason: self.callGetDisabledReasonFuncWithSelectedCommit(self.getDisabledReasonForSquashDown),
-			Description:       self.c.Tr.SquashDown,
+			Key:     opts.GetKey(opts.Config.Commits.SquashDown),
+			Handler: self.withItem(self.squashDown),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.getDisabledReasonForSquashDown),
+			),
+			Description: self.c.Tr.SquashDown,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Commits.MarkCommitAsFixup),
-			Handler:           self.checkSelected(self.fixup),
-			GetDisabledReason: self.callGetDisabledReasonFuncWithSelectedCommit(self.getDisabledReasonForFixup),
-			Description:       self.c.Tr.FixupCommit,
+			Key:     opts.GetKey(opts.Config.Commits.MarkCommitAsFixup),
+			Handler: self.withItem(self.fixup),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.getDisabledReasonForFixup),
+			),
+			Description: self.c.Tr.FixupCommit,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Commits.RenameCommit),
-			Handler:           self.checkSelected(self.reword),
-			GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Reword),
-			Description:       self.c.Tr.RewordCommit,
+			Key:     opts.GetKey(opts.Config.Commits.RenameCommit),
+			Handler: self.withItem(self.reword),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.rebaseCommandEnabled(todo.Reword)),
+			),
+			Description: self.c.Tr.RewordCommit,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Commits.RenameCommitWithEditor),
-			Handler:           self.checkSelected(self.rewordEditor),
-			GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Reword),
-			Description:       self.c.Tr.RenameCommitEditor,
+			Key:     opts.GetKey(opts.Config.Commits.RenameCommitWithEditor),
+			Handler: self.withItem(self.rewordEditor),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.rebaseCommandEnabled(todo.Reword)),
+			),
+			Description: self.c.Tr.RenameCommitEditor,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Universal.Remove),
-			Handler:           self.checkSelected(self.drop),
-			GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Drop),
-			Description:       self.c.Tr.DeleteCommit,
+			Key:     opts.GetKey(opts.Config.Universal.Remove),
+			Handler: self.withItem(self.drop),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.rebaseCommandEnabled(todo.Drop)),
+			),
+			Description: self.c.Tr.DeleteCommit,
 		},
 		{
-			Key:               opts.GetKey(editCommitKey),
-			Handler:           self.checkSelected(self.edit),
-			GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Edit),
-			Description:       self.c.Tr.EditCommit,
+			Key:     opts.GetKey(editCommitKey),
+			Handler: self.withItem(self.edit),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.rebaseCommandEnabled(todo.Edit)),
+			),
+			Description: self.c.Tr.EditCommit,
 		},
 		{
 			// The user-facing description here is 'Start interactive rebase' but internally
 			// we're calling it 'quick-start interactive rebase' to differentiate it from
 			// when you manually select the base commit.
 			Key:               opts.GetKey(opts.Config.Commits.StartInteractiveRebase),
-			Handler:           self.checkSelected(self.quickStartInteractiveRebase),
+			Handler:           self.withItem(self.quickStartInteractiveRebase),
 			GetDisabledReason: self.require(self.notMidRebase, self.canFindCommitForQuickStart),
 			Description:       self.c.Tr.QuickStartInteractiveRebase,
 			Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{
@@ -96,45 +114,50 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 			}),
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Commits.PickCommit),
-			Handler:           self.checkSelected(self.pick),
-			GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Pick),
-			Description:       self.c.Tr.PickCommit,
+			Key:     opts.GetKey(opts.Config.Commits.PickCommit),
+			Handler: self.withItem(self.pick),
+			GetDisabledReason: self.require(
+				self.singleItemSelected(self.rebaseCommandEnabled(todo.Pick)),
+			),
+			Description: self.c.Tr.PickCommit,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.CreateFixupCommit),
-			Handler:           self.checkSelected(self.createFixupCommit),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.createFixupCommit),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.CreateFixupCommitDescription,
 		},
 		{
-			Key:               opts.GetKey(opts.Config.Commits.SquashAboveCommits),
-			Handler:           self.checkSelected(self.squashAllAboveFixupCommits),
-			GetDisabledReason: self.callGetDisabledReasonFuncWithSelectedCommit(self.getDisabledReasonForSquashAllAboveFixupCommits),
-			Description:       self.c.Tr.SquashAboveCommits,
+			Key:     opts.GetKey(opts.Config.Commits.SquashAboveCommits),
+			Handler: self.withItem(self.squashAllAboveFixupCommits),
+			GetDisabledReason: self.require(
+				self.notMidRebase,
+				self.singleItemSelected(),
+			),
+			Description: self.c.Tr.SquashAboveCommits,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.MoveDownCommit),
-			Handler:           self.checkSelected(self.moveDown),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.moveDown),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.MoveDownCommit,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.MoveUpCommit),
-			Handler:           self.checkSelected(self.moveUp),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.moveUp),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.MoveUpCommit,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.PasteCommits),
 			Handler:           self.paste,
-			GetDisabledReason: self.getDisabledReasonForPaste,
+			GetDisabledReason: self.require(self.canPaste),
 			Description:       self.c.Tr.PasteCommits,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.MarkCommitAsBaseForRebase),
-			Handler:           self.checkSelected(self.markAsBaseCommit),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.markAsBaseCommit),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.MarkAsBaseCommit,
 			Tooltip:           self.c.Tr.MarkAsBaseCommitTooltip,
 		},
@@ -161,27 +184,27 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
 	bindings := append(outsideFilterModeBindings, []*types.Binding{
 		{
 			Key:               opts.GetKey(opts.Config.Commits.AmendToCommit),
-			Handler:           self.checkSelected(self.amendTo),
-			GetDisabledReason: self.callGetDisabledReasonFuncWithSelectedCommit(self.getDisabledReasonForAmendTo),
+			Handler:           self.withItem(self.amendTo),
+			GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)),
 			Description:       self.c.Tr.AmendToCommit,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.ResetCommitAuthor),
-			Handler:           self.checkSelected(self.amendAttribute),
-			GetDisabledReason: self.callGetDisabledReasonFuncWithSelectedCommit(self.getDisabledReasonForAmendTo),
+			Handler:           self.withItem(self.amendAttribute),
+			GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)),
 			Description:       self.c.Tr.SetResetCommitAuthor,
 			OpensMenu:         true,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.RevertCommit),
-			Handler:           self.checkSelected(self.revert),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.revert),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.RevertCommit,
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Commits.CreateTag),
-			Handler:           self.checkSelected(self.createTag),
-			GetDisabledReason: self.disabledIfNoSelectedCommit(),
+			Handler:           self.withItem(self.createTag),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 			Description:       self.c.Tr.TagCommit,
 		},
 		{
@@ -266,7 +289,7 @@ func (self *LocalCommitsController) getDisabledReasonForSquashDown(commit *model
 		return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
 	}
 
-	return self.rebaseCommandEnabled(todo.Squash, commit)
+	return self.rebaseCommandEnabled(todo.Squash)(commit)
 }
 
 func (self *LocalCommitsController) fixup(commit *models.Commit) error {
@@ -295,7 +318,7 @@ func (self *LocalCommitsController) getDisabledReasonForFixup(commit *models.Com
 		return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
 	}
 
-	return self.rebaseCommandEnabled(todo.Squash, commit)
+	return self.rebaseCommandEnabled(todo.Squash)(commit)
 }
 
 func (self *LocalCommitsController) reword(commit *models.Commit) error {
@@ -528,36 +551,38 @@ func (self *LocalCommitsController) handleMidRebaseCommand(action todo.TodoComma
 	})
 }
 
-func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand, commit *models.Commit) *types.DisabledReason {
-	if commit.Action == models.ActionConflict {
-		return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
-	}
+func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand) func(*models.Commit) *types.DisabledReason {
+	return func(commit *models.Commit) *types.DisabledReason {
+		if commit.Action == models.ActionConflict {
+			return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
+		}
 
-	if !commit.IsTODO() {
-		if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
-			// If we are in a rebase, the only action that is allowed for
-			// non-todo commits is rewording the current head commit
-			if !(action == todo.Reword && self.isHeadCommit()) {
-				return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
+		if !commit.IsTODO() {
+			if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
+				// If we are in a rebase, the only action that is allowed for
+				// non-todo commits is rewording the current head commit
+				if !(action == todo.Reword && self.isHeadCommit()) {
+					return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
+				}
 			}
+
+			return nil
+		}
+
+		// for now we do not support setting 'reword' because it requires an editor
+		// and that means we either unconditionally wait around for the subprocess to ask for
+		// our input or we set a lazygit client as the EDITOR env variable and have it
+		// request us to edit the commit message when prompted.
+		if action == todo.Reword {
+			return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported}
+		}
+
+		if allowed := isChangeOfRebaseTodoAllowed(action); !allowed {
+			return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
 		}
 
 		return nil
 	}
-
-	// for now we do not support setting 'reword' because it requires an editor
-	// and that means we either unconditionally wait around for the subprocess to ask for
-	// our input or we set a lazygit client as the EDITOR env variable and have it
-	// request us to edit the commit message when prompted.
-	if action == todo.Reword {
-		return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported}
-	}
-
-	if allowed := isChangeOfRebaseTodoAllowed(action); !allowed {
-		return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
-	}
-
-	return nil
 }
 
 func (self *LocalCommitsController) moveDown(commit *models.Commit) error {
@@ -687,7 +712,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
 	})
 }
 
-func (self *LocalCommitsController) getDisabledReasonForAmendTo(commit *models.Commit) *types.DisabledReason {
+func (self *LocalCommitsController) canAmend(commit *models.Commit) *types.DisabledReason {
 	if !self.isHeadCommit() && self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
 		return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
 	}
@@ -870,14 +895,6 @@ func (self *LocalCommitsController) squashAllAboveFixupCommits(commit *models.Co
 	})
 }
 
-func (self *LocalCommitsController) getDisabledReasonForSquashAllAboveFixupCommits(commit *models.Commit) *types.DisabledReason {
-	if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
-		return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
-	}
-
-	return nil
-}
-
 // For getting disabled reason
 func (self *LocalCommitsController) notMidRebase() *types.DisabledReason {
 	if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE {
@@ -1016,39 +1033,6 @@ func (self *LocalCommitsController) handleOpenLogMenu() error {
 	})
 }
 
-func (self *LocalCommitsController) checkSelected(callback func(*models.Commit) error) func() error {
-	return func() error {
-		commit := self.context().GetSelected()
-		if commit == nil {
-			// The enabled callback should have checked for this
-			panic("no commit selected")
-		}
-
-		return callback(commit)
-	}
-}
-
-func (self *LocalCommitsController) callGetDisabledReasonFuncWithSelectedCommit(callback func(*models.Commit) *types.DisabledReason) func() *types.DisabledReason {
-	return func() *types.DisabledReason {
-		commit := self.context().GetSelected()
-		if commit == nil {
-			return &types.DisabledReason{Text: self.c.Tr.NoCommitSelected}
-		}
-
-		return callback(commit)
-	}
-}
-
-func (self *LocalCommitsController) disabledIfNoSelectedCommit() func() *types.DisabledReason {
-	return self.callGetDisabledReasonFuncWithSelectedCommit(func(*models.Commit) *types.DisabledReason { return nil })
-}
-
-func (self *LocalCommitsController) getDisabledReasonForRebaseCommandWithSelectedCommit(action todo.TodoCommand) func() *types.DisabledReason {
-	return self.callGetDisabledReasonFuncWithSelectedCommit(func(commit *models.Commit) *types.DisabledReason {
-		return self.rebaseCommandEnabled(action, commit)
-	})
-}
-
 func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) error {
 	return func(types.OnFocusOpts) error {
 		context := self.context()
@@ -1065,10 +1049,6 @@ func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) error {
 	}
 }
 
-func (self *LocalCommitsController) Context() types.Context {
-	return self.context()
-}
-
 func (self *LocalCommitsController) context() *context.LocalCommitsContext {
 	return self.c.Contexts().LocalCommits
 }
@@ -1077,7 +1057,7 @@ func (self *LocalCommitsController) paste() error {
 	return self.c.Helpers().CherryPick.Paste()
 }
 
-func (self *LocalCommitsController) getDisabledReasonForPaste() *types.DisabledReason {
+func (self *LocalCommitsController) canPaste() *types.DisabledReason {
 	if !self.c.Helpers().CherryPick.CanPaste() {
 		return &types.DisabledReason{Text: self.c.Tr.NoCopiedCommits}
 	}
@@ -1099,19 +1079,6 @@ func (self *LocalCommitsController) isHeadCommit() bool {
 	return models.IsHeadCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx())
 }
 
-// Convenience function for composing multiple disabled reason functions
-func (self *LocalCommitsController) require(callbacks ...func() *types.DisabledReason) func() *types.DisabledReason {
-	return func() *types.DisabledReason {
-		for _, callback := range callbacks {
-			if disabledReason := callback(); disabledReason != nil {
-				return disabledReason
-			}
-		}
-
-		return nil
-	}
-}
-
 func isChangeOfRebaseTodoAllowed(action todo.TodoCommand) bool {
 	allowedActions := []todo.TodoCommand{
 		todo.Pick,
diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go
index 0af32ef71..133840cef 100644
--- a/pkg/gui/controllers/menu_controller.go
+++ b/pkg/gui/controllers/menu_controller.go
@@ -7,17 +7,23 @@ import (
 
 type MenuController struct {
 	baseController
+	*ListControllerTrait[*types.MenuItem]
 	c *ControllerCommon
 }
 
 var _ types.IController = &MenuController{}
 
 func NewMenuController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *MenuController {
 	return &MenuController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*types.MenuItem](
+			c,
+			c.Contexts().Menu,
+			c.Contexts().Menu.GetSelected,
+		),
+		c: c,
 	}
 }
 
@@ -26,14 +32,16 @@ func NewMenuController(
 func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:     opts.GetKey(opts.Config.Universal.Select),
-			Handler: self.press,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.press),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Confirm),
-			Handler:     self.press,
-			Description: self.c.Tr.Execute,
-			Display:     true,
+			Key:               opts.GetKey(opts.Config.Universal.Confirm),
+			Handler:           self.withItem(self.press),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Execute,
+			Display:           true,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Universal.Return),
@@ -47,7 +55,7 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.
 }
 
 func (self *MenuController) GetOnClick() func() error {
-	return self.press
+	return self.withItemGraceful(self.press)
 }
 
 func (self *MenuController) GetOnFocus() func(types.OnFocusOpts) error {
@@ -60,8 +68,8 @@ func (self *MenuController) GetOnFocus() func(types.OnFocusOpts) error {
 	}
 }
 
-func (self *MenuController) press() error {
-	return self.context().OnMenuPress(self.context().GetSelected())
+func (self *MenuController) press(selectedItem *types.MenuItem) error {
+	return self.context().OnMenuPress(selectedItem)
 }
 
 func (self *MenuController) close() error {
@@ -73,10 +81,6 @@ func (self *MenuController) close() error {
 	return self.c.PopContext()
 }
 
-func (self *MenuController) Context() types.Context {
-	return self.context()
-}
-
 func (self *MenuController) context() *context.MenuContext {
 	return self.c.Contexts().Menu
 }
diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go
index 450cd1816..730826ba8 100644
--- a/pkg/gui/controllers/merge_conflicts_controller.go
+++ b/pkg/gui/controllers/merge_conflicts_controller.go
@@ -17,11 +17,11 @@ type MergeConflictsController struct {
 var _ types.IController = &MergeConflictsController{}
 
 func NewMergeConflictsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *MergeConflictsController {
 	return &MergeConflictsController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go
index ff5bd777b..dcef64677 100644
--- a/pkg/gui/controllers/patch_building_controller.go
+++ b/pkg/gui/controllers/patch_building_controller.go
@@ -14,11 +14,11 @@ type PatchBuildingController struct {
 var _ types.IController = &PatchBuildingController{}
 
 func NewPatchBuildingController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *PatchBuildingController {
 	return &PatchBuildingController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/reflog_commits_controller.go b/pkg/gui/controllers/reflog_commits_controller.go
index 9cd5dd050..6e0228784 100644
--- a/pkg/gui/controllers/reflog_commits_controller.go
+++ b/pkg/gui/controllers/reflog_commits_controller.go
@@ -1,23 +1,30 @@
 package controllers
 
 import (
+	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 )
 
 type ReflogCommitsController struct {
 	baseController
+	*ListControllerTrait[*models.Commit]
 	c *ControllerCommon
 }
 
 var _ types.IController = &ReflogCommitsController{}
 
 func NewReflogCommitsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *ReflogCommitsController {
 	return &ReflogCommitsController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.Commit](
+			c,
+			c.Contexts().ReflogCommits,
+			c.Contexts().ReflogCommits.GetSelected,
+		),
+		c: c,
 	}
 }
 
diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go
index d6f9e9036..25797003b 100644
--- a/pkg/gui/controllers/remote_branches_controller.go
+++ b/pkg/gui/controllers/remote_branches_controller.go
@@ -11,17 +11,23 @@ import (
 
 type RemoteBranchesController struct {
 	baseController
+	*ListControllerTrait[*models.RemoteBranch]
 	c *ControllerCommon
 }
 
 var _ types.IController = &RemoteBranchesController{}
 
 func NewRemoteBranchesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *RemoteBranchesController {
 	return &RemoteBranchesController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.RemoteBranch](
+			c,
+			c.Contexts().RemoteBranches,
+			c.Contexts().RemoteBranches.GetSelected,
+		),
+		c: c,
 	}
 }
 
@@ -30,33 +36,39 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts)
 		{
 			Key: opts.GetKey(opts.Config.Universal.Select),
 			// gonna use the exact same handler as the 'n' keybinding because everybody wants this to happen when they checkout a remote branch
-			Handler:     self.checkSelected(self.newLocalBranch),
-			Description: self.c.Tr.Checkout,
+			Handler:           self.withItem(self.newLocalBranch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Checkout,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.New),
-			Handler:     self.checkSelected(self.newLocalBranch),
-			Description: self.c.Tr.NewBranch,
+			Key:               opts.GetKey(opts.Config.Universal.New),
+			Handler:           self.withItem(self.newLocalBranch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.NewBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
-			Handler:     opts.Guards.OutsideFilterMode(self.checkSelected(self.merge)),
-			Description: self.c.Tr.MergeIntoCurrentBranch,
+			Key:               opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch),
+			Handler:           opts.Guards.OutsideFilterMode(self.withItem(self.merge)),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.MergeIntoCurrentBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.RebaseBranch),
-			Handler:     opts.Guards.OutsideFilterMode(self.checkSelected(self.rebase)),
-			Description: self.c.Tr.RebaseBranch,
+			Key:               opts.GetKey(opts.Config.Branches.RebaseBranch),
+			Handler:           opts.Guards.OutsideFilterMode(self.withItem(self.rebase)),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.RebaseBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.delete),
-			Description: self.c.Tr.DeleteRemoteTag,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.delete),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.DeleteRemoteTag,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.SetUpstream),
-			Handler:     self.checkSelected(self.setAsUpstream),
-			Description: self.c.Tr.SetAsUpstream,
+			Key:               opts.GetKey(opts.Config.Branches.SetUpstream),
+			Handler:           self.withItem(self.setAsUpstream),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.SetAsUpstream,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Branches.SortOrder),
@@ -65,10 +77,11 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts)
 			OpensMenu:   true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
-			Handler:     self.checkSelected(self.createResetMenu),
-			Description: self.c.Tr.ViewResetOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Commits.ViewResetOptions),
+			Handler:           self.withItem(self.createResetMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewResetOptions,
+			OpensMenu:         true,
 		},
 	}
 }
@@ -96,25 +109,10 @@ func (self *RemoteBranchesController) GetOnRenderToMain() func() error {
 	}
 }
 
-func (self *RemoteBranchesController) Context() types.Context {
-	return self.context()
-}
-
 func (self *RemoteBranchesController) context() *context.RemoteBranchesContext {
 	return self.c.Contexts().RemoteBranches
 }
 
-func (self *RemoteBranchesController) checkSelected(callback func(*models.RemoteBranch) error) func() error {
-	return func() error {
-		selectedItem := self.context().GetSelected()
-		if selectedItem == nil {
-			return nil
-		}
-
-		return callback(selectedItem)
-	}
-}
-
 func (self *RemoteBranchesController) delete(selectedBranch *models.RemoteBranch) error {
 	return self.c.Helpers().BranchesHelper.ConfirmDeleteRemote(selectedBranch.RemoteName, selectedBranch.Name)
 }
diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go
index 47f14417e..ebd232935 100644
--- a/pkg/gui/controllers/remotes_controller.go
+++ b/pkg/gui/controllers/remotes_controller.go
@@ -14,6 +14,7 @@ import (
 
 type RemotesController struct {
 	baseController
+	*ListControllerTrait[*models.Remote]
 	c *ControllerCommon
 
 	setRemoteBranches func([]*models.RemoteBranch)
@@ -22,12 +23,17 @@ type RemotesController struct {
 var _ types.IController = &RemotesController{}
 
 func NewRemotesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 	setRemoteBranches func([]*models.RemoteBranch),
 ) *RemotesController {
 	return &RemotesController{
-		baseController:    baseController{},
-		c:                 common,
+		baseController: baseController{},
+		ListControllerTrait: NewListControllerTrait[*models.Remote](
+			c,
+			c.Contexts().Remotes,
+			c.Contexts().Remotes.GetSelected,
+		),
+		c:                 c,
 		setRemoteBranches: setRemoteBranches,
 	}
 }
@@ -35,13 +41,15 @@ func NewRemotesController(
 func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:     opts.GetKey(opts.Config.Universal.GoInto),
-			Handler: self.checkSelected(self.enter),
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.FetchRemote),
-			Handler:     self.checkSelected(self.fetch),
-			Description: self.c.Tr.FetchRemote,
+			Key:               opts.GetKey(opts.Config.Branches.FetchRemote),
+			Handler:           self.withItem(self.fetch),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.FetchRemote,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Universal.New),
@@ -49,24 +57,22 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ
 			Description: self.c.Tr.AddNewRemote,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.remove),
-			Description: self.c.Tr.RemoveRemote,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.remove),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.RemoveRemote,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Edit),
-			Handler:     self.checkSelected(self.edit),
-			Description: self.c.Tr.EditRemote,
+			Key:               opts.GetKey(opts.Config.Universal.Edit),
+			Handler:           self.withItem(self.edit),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EditRemote,
 		},
 	}
 
 	return bindings
 }
 
-func (self *RemotesController) Context() types.Context {
-	return self.context()
-}
-
 func (self *RemotesController) context() *context.RemotesContext {
 	return self.c.Contexts().Remotes
 }
@@ -94,7 +100,7 @@ func (self *RemotesController) GetOnRenderToMain() func() error {
 }
 
 func (self *RemotesController) GetOnClick() func() error {
-	return self.checkSelected(self.enter)
+	return self.withItemGraceful(self.enter)
 }
 
 func (self *RemotesController) enter(remote *models.Remote) error {
@@ -208,14 +214,3 @@ func (self *RemotesController) fetch(remote *models.Remote) error {
 		return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
 	})
 }
-
-func (self *RemotesController) checkSelected(callback func(*models.Remote) error) func() error {
-	return func() error {
-		file := self.context().GetSelected()
-		if file == nil {
-			return nil
-		}
-
-		return callback(file)
-	}
-}
diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go
index 014edd094..65dd23383 100644
--- a/pkg/gui/controllers/search_prompt_controller.go
+++ b/pkg/gui/controllers/search_prompt_controller.go
@@ -13,11 +13,11 @@ type SearchPromptController struct {
 var _ types.IController = &SearchPromptController{}
 
 func NewSearchPromptController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *SearchPromptController {
 	return &SearchPromptController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/side_window_controller.go b/pkg/gui/controllers/side_window_controller.go
index a2325c54d..0b9877494 100644
--- a/pkg/gui/controllers/side_window_controller.go
+++ b/pkg/gui/controllers/side_window_controller.go
@@ -9,8 +9,8 @@ type SideWindowControllerFactory struct {
 	c *ControllerCommon
 }
 
-func NewSideWindowControllerFactory(common *ControllerCommon) *SideWindowControllerFactory {
-	return &SideWindowControllerFactory{c: common}
+func NewSideWindowControllerFactory(c *ControllerCommon) *SideWindowControllerFactory {
+	return &SideWindowControllerFactory{c: c}
 }
 
 func (self *SideWindowControllerFactory) Create(context types.Context) types.IController {
@@ -24,12 +24,12 @@ type SideWindowController struct {
 }
 
 func NewSideWindowController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 	context types.Context,
 ) *SideWindowController {
 	return &SideWindowController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 		context:        context,
 	}
 }
diff --git a/pkg/gui/controllers/snake_controller.go b/pkg/gui/controllers/snake_controller.go
index 074a4a6fb..b8e3327f7 100644
--- a/pkg/gui/controllers/snake_controller.go
+++ b/pkg/gui/controllers/snake_controller.go
@@ -13,11 +13,11 @@ type SnakeController struct {
 var _ types.IController = &SnakeController{}
 
 func NewSnakeController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *SnakeController {
 	return &SnakeController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go
index 31432fa68..42dac9aa3 100644
--- a/pkg/gui/controllers/staging_controller.go
+++ b/pkg/gui/controllers/staging_controller.go
@@ -23,14 +23,14 @@ type StagingController struct {
 var _ types.IController = &StagingController{}
 
 func NewStagingController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 	context types.IPatchExplorerContext,
 	otherContext types.IPatchExplorerContext,
 	staged bool,
 ) *StagingController {
 	return &StagingController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 		context:        context,
 		otherContext:   otherContext,
 		staged:         staged,
diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go
index acd66cd31..ddef24283 100644
--- a/pkg/gui/controllers/stash_controller.go
+++ b/pkg/gui/controllers/stash_controller.go
@@ -9,46 +9,57 @@ import (
 
 type StashController struct {
 	baseController
+	*ListControllerTrait[*models.StashEntry]
 	c *ControllerCommon
 }
 
 var _ types.IController = &StashController{}
 
 func NewStashController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *StashController {
 	return &StashController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.StashEntry](
+			c,
+			c.Contexts().Stash,
+			c.Contexts().Stash.GetSelected,
+		),
+		c: c,
 	}
 }
 
 func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.checkSelected(self.handleStashApply),
-			Description: self.c.Tr.Apply,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.handleStashApply),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Apply,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Stash.PopStash),
-			Handler:     self.checkSelected(self.handleStashPop),
-			Description: self.c.Tr.Pop,
+			Key:               opts.GetKey(opts.Config.Stash.PopStash),
+			Handler:           self.withItem(self.handleStashPop),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Pop,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.handleStashDrop),
-			Description: self.c.Tr.Drop,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.handleStashDrop),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Drop,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.New),
-			Handler:     self.checkSelected(self.handleNewBranchOffStashEntry),
-			Description: self.c.Tr.NewBranch,
+			Key:               opts.GetKey(opts.Config.Universal.New),
+			Handler:           self.withItem(self.handleNewBranchOffStashEntry),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.NewBranch,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Stash.RenameStash),
-			Handler:     self.checkSelected(self.handleRenameStashEntry),
-			Description: self.c.Tr.RenameStash,
+			Key:               opts.GetKey(opts.Config.Stash.RenameStash),
+			Handler:           self.withItem(self.handleRenameStashEntry),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.RenameStash,
 		},
 	}
 
@@ -80,21 +91,6 @@ func (self *StashController) GetOnRenderToMain() func() error {
 	}
 }
 
-func (self *StashController) checkSelected(callback func(*models.StashEntry) error) func() error {
-	return func() error {
-		item := self.context().GetSelected()
-		if item == nil {
-			return nil
-		}
-
-		return callback(item)
-	}
-}
-
-func (self *StashController) Context() types.Context {
-	return self.context()
-}
-
 func (self *StashController) context() *context.StashContext {
 	return self.c.Contexts().Stash
 }
diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go
index 5d35d9f47..59df8e352 100644
--- a/pkg/gui/controllers/status_controller.go
+++ b/pkg/gui/controllers/status_controller.go
@@ -22,11 +22,11 @@ type StatusController struct {
 var _ types.IController = &StatusController{}
 
 func NewStatusController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *StatusController {
 	return &StatusController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go
index 46dc0df98..a4ebfb5cd 100644
--- a/pkg/gui/controllers/sub_commits_controller.go
+++ b/pkg/gui/controllers/sub_commits_controller.go
@@ -2,23 +2,30 @@ package controllers
 
 import (
 	"github.com/jesseduffield/gocui"
+	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 )
 
 type SubCommitsController struct {
 	baseController
+	*ListControllerTrait[*models.Commit]
 	c *ControllerCommon
 }
 
 var _ types.IController = &SubCommitsController{}
 
 func NewSubCommitsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *SubCommitsController {
 	return &SubCommitsController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.Commit](
+			c,
+			c.Contexts().SubCommits,
+			c.Contexts().SubCommits.GetSelected,
+		),
+		c: c,
 	}
 }
 
diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go
index d7ed12132..dc43ff35e 100644
--- a/pkg/gui/controllers/submodules_controller.go
+++ b/pkg/gui/controllers/submodules_controller.go
@@ -14,41 +14,51 @@ import (
 
 type SubmodulesController struct {
 	baseController
+	*ListControllerTrait[*models.SubmoduleConfig]
 	c *ControllerCommon
 }
 
 var _ types.IController = &SubmodulesController{}
 
 func NewSubmodulesController(
-	controllerCommon *ControllerCommon,
+	c *ControllerCommon,
 ) *SubmodulesController {
 	return &SubmodulesController{
 		baseController: baseController{},
-		c:              controllerCommon,
+		ListControllerTrait: NewListControllerTrait[*models.SubmoduleConfig](
+			c,
+			c.Contexts().Submodules,
+			c.Contexts().Submodules.GetSelected,
+		),
+		c: c,
 	}
 }
 
 func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	return []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.GoInto),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.EnterSubmodule,
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EnterSubmodule,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.EnterSubmodule,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EnterSubmodule,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.remove),
-			Description: self.c.Tr.RemoveSubmodule,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.remove),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.RemoveSubmodule,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Submodules.Update),
-			Handler:     self.checkSelected(self.update),
-			Description: self.c.Tr.SubmoduleUpdate,
+			Key:               opts.GetKey(opts.Config.Submodules.Update),
+			Handler:           self.withItem(self.update),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.SubmoduleUpdate,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Universal.New),
@@ -56,14 +66,16 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*
 			Description: self.c.Tr.AddSubmodule,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Edit),
-			Handler:     self.checkSelected(self.editURL),
-			Description: self.c.Tr.EditSubmoduleUrl,
+			Key:               opts.GetKey(opts.Config.Universal.Edit),
+			Handler:           self.withItem(self.editURL),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.EditSubmoduleUrl,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Submodules.Init),
-			Handler:     self.checkSelected(self.init),
-			Description: self.c.Tr.InitSubmodule,
+			Key:               opts.GetKey(opts.Config.Submodules.Init),
+			Handler:           self.withItem(self.init),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.InitSubmodule,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Submodules.BulkMenu),
@@ -80,7 +92,7 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*
 }
 
 func (self *SubmodulesController) GetOnClick() func() error {
-	return self.checkSelected(self.enter)
+	return self.withItemGraceful(self.enter)
 }
 
 func (self *SubmodulesController) GetOnRenderToMain() func() error {
@@ -265,21 +277,6 @@ func (self *SubmodulesController) easterEgg() error {
 	return self.c.PushContext(self.c.Contexts().Snake)
 }
 
-func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error {
-	return func() error {
-		submodule := self.context().GetSelected()
-		if submodule == nil {
-			return nil
-		}
-
-		return callback(submodule)
-	}
-}
-
-func (self *SubmodulesController) Context() types.Context {
-	return self.context()
-}
-
 func (self *SubmodulesController) context() *context.SubmodulesContext {
 	return self.c.Contexts().Submodules
 }
diff --git a/pkg/gui/controllers/suggestions_controller.go b/pkg/gui/controllers/suggestions_controller.go
index 17b8915a1..dbb2b9812 100644
--- a/pkg/gui/controllers/suggestions_controller.go
+++ b/pkg/gui/controllers/suggestions_controller.go
@@ -7,25 +7,32 @@ import (
 
 type SuggestionsController struct {
 	baseController
+	*ListControllerTrait[*types.Suggestion]
 	c *ControllerCommon
 }
 
 var _ types.IController = &SuggestionsController{}
 
 func NewSuggestionsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *SuggestionsController {
 	return &SuggestionsController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*types.Suggestion](
+			c,
+			c.Contexts().Suggestions,
+			c.Contexts().Suggestions.GetSelected,
+		),
+		c: c,
 	}
 }
 
 func (self *SuggestionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:     opts.GetKey(opts.Config.Universal.Confirm),
-			Handler: func() error { return self.context().State.OnConfirm() },
+			Key:               opts.GetKey(opts.Config.Universal.Confirm),
+			Handler:           func() error { return self.context().State.OnConfirm() },
+			GetDisabledReason: self.require(self.singleItemSelected()),
 		},
 		{
 			Key:     opts.GetKey(opts.Config.Universal.Return),
@@ -47,10 +54,6 @@ func (self *SuggestionsController) GetOnFocusLost() func(types.OnFocusLostOpts)
 	}
 }
 
-func (self *SuggestionsController) Context() types.Context {
-	return self.context()
-}
-
 func (self *SuggestionsController) context() *context.SuggestionsContext {
 	return self.c.Contexts().Suggestions
 }
diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go
index af2b38984..069726147 100644
--- a/pkg/gui/controllers/switch_to_diff_files_controller.go
+++ b/pkg/gui/controllers/switch_to_diff_files_controller.go
@@ -10,13 +10,16 @@ import (
 var _ types.IController = &SwitchToDiffFilesController{}
 
 type CanSwitchToDiffFiles interface {
-	types.Context
+	types.IListContext
 	CanRebase() bool
 	GetSelectedRef() types.Ref
 }
 
+// Not using our ListControllerTrait because our 'selected' item is not a list item
+// but an attribute on it i.e. the ref of an item.
 type SwitchToDiffFilesController struct {
 	baseController
+	*ListControllerTrait[types.Ref]
 	c                *ControllerCommon
 	context          CanSwitchToDiffFiles
 	diffFilesContext *context.CommitFilesContext
@@ -28,7 +31,12 @@ func NewSwitchToDiffFilesController(
 	diffFilesContext *context.CommitFilesContext,
 ) *SwitchToDiffFilesController {
 	return &SwitchToDiffFilesController{
-		baseController:   baseController{},
+		baseController: baseController{},
+		ListControllerTrait: NewListControllerTrait[types.Ref](
+			c,
+			context,
+			context.GetSelectedRef,
+		),
 		c:                c,
 		context:          context,
 		diffFilesContext: diffFilesContext,
@@ -38,9 +46,10 @@ func NewSwitchToDiffFilesController(
 func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.GoInto),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.ViewItemFiles,
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewItemFiles,
 		},
 	}
 
@@ -48,18 +57,7 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
 }
 
 func (self *SwitchToDiffFilesController) GetOnClick() func() error {
-	return self.checkSelected(self.enter)
-}
-
-func (self *SwitchToDiffFilesController) checkSelected(callback func(types.Ref) error) func() error {
-	return func() error {
-		ref := self.context.GetSelectedRef()
-		if ref == nil {
-			return nil
-		}
-
-		return callback(ref)
-	}
+	return self.withItemGraceful(self.enter)
 }
 
 func (self *SwitchToDiffFilesController) enter(ref types.Ref) error {
@@ -70,10 +68,6 @@ func (self *SwitchToDiffFilesController) enter(ref types.Ref) error {
 	})
 }
 
-func (self *SwitchToDiffFilesController) Context() types.Context {
-	return self.context
-}
-
 func (self *SwitchToDiffFilesController) viewFiles(opts SwitchToCommitFilesContextOpts) error {
 	diffFilesContext := self.diffFilesContext
 
diff --git a/pkg/gui/controllers/switch_to_sub_commits_controller.go b/pkg/gui/controllers/switch_to_sub_commits_controller.go
index 3109f559e..d7bb0a97d 100644
--- a/pkg/gui/controllers/switch_to_sub_commits_controller.go
+++ b/pkg/gui/controllers/switch_to_sub_commits_controller.go
@@ -8,34 +8,43 @@ import (
 var _ types.IController = &SwitchToSubCommitsController{}
 
 type CanSwitchToSubCommits interface {
-	types.Context
+	types.IListContext
 	GetSelectedRef() types.Ref
 	ShowBranchHeadsInSubCommits() bool
 }
 
+// Not using our ListControllerTrait because our 'selected' item is not a list item
+// but an attribute on it i.e. the ref of an item.
 type SwitchToSubCommitsController struct {
 	baseController
+	*ListControllerTrait[types.Ref]
 	c       *ControllerCommon
 	context CanSwitchToSubCommits
 }
 
 func NewSwitchToSubCommitsController(
-	controllerCommon *ControllerCommon,
+	c *ControllerCommon,
 	context CanSwitchToSubCommits,
 ) *SwitchToSubCommitsController {
 	return &SwitchToSubCommitsController{
 		baseController: baseController{},
-		c:              controllerCommon,
-		context:        context,
+		ListControllerTrait: NewListControllerTrait[types.Ref](
+			c,
+			context,
+			context.GetSelectedRef,
+		),
+		c:       c,
+		context: context,
 	}
 }
 
 func (self *SwitchToSubCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Handler:     self.viewCommits,
-			Key:         opts.GetKey(opts.Config.Universal.GoInto),
-			Description: self.c.Tr.ViewCommits,
+			Handler:           self.viewCommits,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Key:               opts.GetKey(opts.Config.Universal.GoInto),
+			Description:       self.c.Tr.ViewCommits,
 		},
 	}
 
@@ -59,7 +68,3 @@ func (self *SwitchToSubCommitsController) viewCommits() error {
 		ShowBranchHeads: self.context.ShowBranchHeadsInSubCommits(),
 	})
 }
-
-func (self *SwitchToSubCommitsController) Context() types.Context {
-	return self.context
-}
diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go
index e96dde2e4..7baebc54c 100644
--- a/pkg/gui/controllers/tags_controller.go
+++ b/pkg/gui/controllers/tags_controller.go
@@ -10,37 +10,46 @@ import (
 
 type TagsController struct {
 	baseController
+	*ListControllerTrait[*models.Tag]
 	c *ControllerCommon
 }
 
 var _ types.IController = &TagsController{}
 
 func NewTagsController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *TagsController {
 	return &TagsController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.Tag](
+			c,
+			c.Contexts().Tags,
+			c.Contexts().Tags.GetSelected,
+		),
+		c: c,
 	}
 }
 
 func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
 	bindings := []*types.Binding{
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.withSelectedTag(self.checkout),
-			Description: self.c.Tr.Checkout,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.checkout),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.Checkout,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.withSelectedTag(self.delete),
-			Description: self.c.Tr.ViewDeleteOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.delete),
+			Description:       self.c.Tr.ViewDeleteOptions,
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			OpensMenu:         true,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Branches.PushTag),
-			Handler:     self.withSelectedTag(self.push),
-			Description: self.c.Tr.PushTag,
+			Key:               opts.GetKey(opts.Config.Branches.PushTag),
+			Handler:           self.withItem(self.push),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.PushTag,
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Universal.New),
@@ -48,10 +57,11 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.
 			Description: self.c.Tr.CreateTag,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
-			Handler:     self.withSelectedTag(self.createResetMenu),
-			Description: self.c.Tr.ViewResetOptions,
-			OpensMenu:   true,
+			Key:               opts.GetKey(opts.Config.Commits.ViewResetOptions),
+			Handler:           self.withItem(self.createResetMenu),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.ViewResetOptions,
+			OpensMenu:         true,
 		},
 	}
 
@@ -215,21 +225,6 @@ func (self *TagsController) create() error {
 	})
 }
 
-func (self *TagsController) withSelectedTag(f func(tag *models.Tag) error) func() error {
-	return func() error {
-		tag := self.context().GetSelected()
-		if tag == nil {
-			return nil
-		}
-
-		return f(tag)
-	}
-}
-
-func (self *TagsController) Context() types.Context {
-	return self.context()
-}
-
 func (self *TagsController) context() *context.TagsContext {
 	return self.c.Contexts().Tags
 }
diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go
index 1546d0c46..c0a754794 100644
--- a/pkg/gui/controllers/undo_controller.go
+++ b/pkg/gui/controllers/undo_controller.go
@@ -27,11 +27,11 @@ type UndoController struct {
 var _ types.IController = &UndoController{}
 
 func NewUndoController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *UndoController {
 	return &UndoController{
 		baseController: baseController{},
-		c:              common,
+		c:              c,
 	}
 }
 
diff --git a/pkg/gui/controllers/worktree_options_controller.go b/pkg/gui/controllers/worktree_options_controller.go
index 8c2c0bbb0..01cc9b362 100644
--- a/pkg/gui/controllers/worktree_options_controller.go
+++ b/pkg/gui/controllers/worktree_options_controller.go
@@ -14,15 +14,21 @@ type CanViewWorktreeOptions interface {
 
 type WorktreeOptionsController struct {
 	baseController
+	*ListControllerTrait[string]
 	c       *ControllerCommon
 	context CanViewWorktreeOptions
 }
 
-func NewWorktreeOptionsController(controllerCommon *ControllerCommon, context CanViewWorktreeOptions) *WorktreeOptionsController {
+func NewWorktreeOptionsController(c *ControllerCommon, context CanViewWorktreeOptions) *WorktreeOptionsController {
 	return &WorktreeOptionsController{
 		baseController: baseController{},
-		c:              controllerCommon,
-		context:        context,
+		ListControllerTrait: NewListControllerTrait[string](
+			c,
+			context,
+			context.GetSelectedItemId,
+		),
+		c:       c,
+		context: context,
 	}
 }
 
@@ -30,7 +36,7 @@ func (self *WorktreeOptionsController) GetKeybindings(opts types.KeybindingsOpts
 	bindings := []*types.Binding{
 		{
 			Key:         opts.GetKey(opts.Config.Worktrees.ViewWorktreeOptions),
-			Handler:     self.checkSelected(self.viewWorktreeOptions),
+			Handler:     self.withItem(self.viewWorktreeOptions),
 			Description: self.c.Tr.ViewWorktreeOptions,
 			OpensMenu:   true,
 		},
@@ -39,21 +45,6 @@ func (self *WorktreeOptionsController) GetKeybindings(opts types.KeybindingsOpts
 	return bindings
 }
 
-func (self *WorktreeOptionsController) checkSelected(callback func(string) error) func() error {
-	return func() error {
-		ref := self.context.GetSelectedItemId()
-		if ref == "" {
-			return nil
-		}
-
-		return callback(ref)
-	}
-}
-
-func (self *WorktreeOptionsController) Context() types.Context {
-	return self.context
-}
-
 func (self *WorktreeOptionsController) viewWorktreeOptions(ref string) error {
 	return self.c.Helpers().Worktree.ViewWorktreeOptions(self.context, ref)
 }
diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go
index c76c3b1de..b634d0607 100644
--- a/pkg/gui/controllers/worktrees_controller.go
+++ b/pkg/gui/controllers/worktrees_controller.go
@@ -13,17 +13,23 @@ import (
 
 type WorktreesController struct {
 	baseController
+	*ListControllerTrait[*models.Worktree]
 	c *ControllerCommon
 }
 
 var _ types.IController = &WorktreesController{}
 
 func NewWorktreesController(
-	common *ControllerCommon,
+	c *ControllerCommon,
 ) *WorktreesController {
 	return &WorktreesController{
 		baseController: baseController{},
-		c:              common,
+		ListControllerTrait: NewListControllerTrait[*models.Worktree](
+			c,
+			c.Contexts().Worktrees,
+			c.Contexts().Worktrees.GetSelected,
+		),
+		c: c,
 	}
 }
 
@@ -35,24 +41,28 @@ func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*t
 			Description: self.c.Tr.CreateWorktree,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Select),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.SwitchToWorktree,
+			Key:               opts.GetKey(opts.Config.Universal.Select),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.SwitchToWorktree,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Confirm),
-			Handler:     self.checkSelected(self.enter),
-			Description: self.c.Tr.SwitchToWorktree,
+			Key:               opts.GetKey(opts.Config.Universal.Confirm),
+			Handler:           self.withItem(self.enter),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.SwitchToWorktree,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.OpenFile),
-			Handler:     self.checkSelected(self.open),
-			Description: self.c.Tr.OpenInEditor,
+			Key:               opts.GetKey(opts.Config.Universal.OpenFile),
+			Handler:           self.withItem(self.open),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.OpenInEditor,
 		},
 		{
-			Key:         opts.GetKey(opts.Config.Universal.Remove),
-			Handler:     self.checkSelected(self.remove),
-			Description: self.c.Tr.RemoveWorktree,
+			Key:               opts.GetKey(opts.Config.Universal.Remove),
+			Handler:           self.withItem(self.remove),
+			GetDisabledReason: self.require(self.singleItemSelected()),
+			Description:       self.c.Tr.RemoveWorktree,
 		},
 	}
 
@@ -113,7 +123,7 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error {
 }
 
 func (self *WorktreesController) GetOnClick() func() error {
-	return self.checkSelected(self.enter)
+	return self.withItemGraceful(self.enter)
 }
 
 func (self *WorktreesController) enter(worktree *models.Worktree) error {
@@ -124,21 +134,6 @@ func (self *WorktreesController) open(worktree *models.Worktree) error {
 	return self.c.Helpers().Files.OpenDirInEditor(worktree.Path)
 }
 
-func (self *WorktreesController) checkSelected(callback func(worktree *models.Worktree) error) func() error {
-	return func() error {
-		worktree := self.context().GetSelected()
-		if worktree == nil {
-			return nil
-		}
-
-		return callback(worktree)
-	}
-}
-
-func (self *WorktreesController) Context() types.Context {
-	return self.context()
-}
-
 func (self *WorktreesController) context() *context.WorktreesContext {
 	return self.c.Contexts().Worktrees
 }
diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go
index c537e8524..9513fff61 100644
--- a/pkg/gui/global_handlers.go
+++ b/pkg/gui/global_handlers.go
@@ -139,6 +139,28 @@ func (gui *Gui) handleCopySelectedSideContextItemToClipboard() error {
 	return nil
 }
 
+func (gui *Gui) getCopySelectedSideContextItemToClipboardDisabledReason() *types.DisabledReason {
+	// important to note that this assumes we've selected an item in a side context
+	currentSideContext := gui.c.CurrentSideContext()
+	if currentSideContext == nil {
+		// This should never happen but if it does we'll just ignore the keypress
+		return nil
+	}
+
+	listContext, ok := currentSideContext.(types.IListContext)
+	if !ok {
+		// This should never happen but if it does we'll just ignore the keypress
+		return nil
+	}
+
+	startIdx, endIdx := listContext.GetList().GetSelectionRange()
+	if startIdx != endIdx {
+		return &types.DisabledReason{Text: gui.Tr.RangeSelectNotSupported}
+	}
+
+	return nil
+}
+
 func (gui *Gui) setCaption(caption string) {
 	gui.Views.Options.FgColor = gocui.ColorWhite
 	gui.Views.Options.FgColor |= gocui.AttrBold
diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go
index 26ce8ec91..90f27ac43 100644
--- a/pkg/gui/keybindings.go
+++ b/pkg/gui/keybindings.go
@@ -123,28 +123,32 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
 			Handler:  self.scrollDownMain,
 		},
 		{
-			ViewName:    "files",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyFileNameToClipboard,
+			ViewName:          "files",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyFileNameToClipboard,
 		},
 		{
-			ViewName:    "localBranches",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyBranchNameToClipboard,
+			ViewName:          "localBranches",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyBranchNameToClipboard,
 		},
 		{
-			ViewName:    "remoteBranches",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyBranchNameToClipboard,
+			ViewName:          "remoteBranches",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyBranchNameToClipboard,
 		},
 		{
-			ViewName:    "commits",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyCommitShaToClipboard,
+			ViewName:          "commits",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyCommitShaToClipboard,
 		},
 		{
 			ViewName:    "commits",
@@ -153,16 +157,18 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
 			Description: self.c.Tr.ResetCherryPick,
 		},
 		{
-			ViewName:    "reflogCommits",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyCommitShaToClipboard,
+			ViewName:          "reflogCommits",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyCommitShaToClipboard,
 		},
 		{
-			ViewName:    "subCommits",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyCommitShaToClipboard,
+			ViewName:          "subCommits",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyCommitShaToClipboard,
 		},
 		{
 			ViewName: "information",
@@ -171,10 +177,11 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
 			Handler:  self.handleInfoClick,
 		},
 		{
-			ViewName:    "commitFiles",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopyCommitFileNameToClipboard,
+			ViewName:          "commitFiles",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopyCommitFileNameToClipboard,
 		},
 		{
 			ViewName:    "",
@@ -240,10 +247,11 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
 			Handler:  self.scrollDownConfirmationPanel,
 		},
 		{
-			ViewName:    "submodules",
-			Key:         opts.GetKey(opts.Config.Universal.CopyToClipboard),
-			Handler:     self.handleCopySelectedSideContextItemToClipboard,
-			Description: self.c.Tr.CopySubmoduleNameToClipboard,
+			ViewName:          "submodules",
+			Key:               opts.GetKey(opts.Config.Universal.CopyToClipboard),
+			Handler:           self.handleCopySelectedSideContextItemToClipboard,
+			GetDisabledReason: self.getCopySelectedSideContextItemToClipboardDisabledReason,
+			Description:       self.c.Tr.CopySubmoduleNameToClipboard,
 		},
 		{
 			ViewName: "extras",
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
index 3557cf5aa..a3f0ad24a 100644
--- a/pkg/gui/types/context.go
+++ b/pkg/gui/types/context.go
@@ -231,6 +231,7 @@ type IListCursor interface {
 	GetRangeStartIdx() (int, bool)
 	GetSelectionRange() (int, int)
 	IsSelectingRange() bool
+	AreMultipleItemsSelected() bool
 	ToggleStickyRange()
 	ExpandNonStickyRange(int)
 }
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index e9d0c9a65..35e7dd687 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -644,7 +644,6 @@ type TranslationSet struct {
 	MarkedCommitMarker                  string
 	PleaseGoToURL                       string
 	DisabledMenuItemPrefix              string
-	NoCommitSelected                    string
 	NoCopiedCommits                     string
 	QuickStartInteractiveRebase         string
 	QuickStartInteractiveRebaseTooltip  string
@@ -652,6 +651,9 @@ type TranslationSet struct {
 	ToggleRangeSelect                   string
 	RangeSelectUp                       string
 	RangeSelectDown                     string
+	RangeSelectNotSupported             string
+	NoItemSelected                      string
+	SelectedItemIsNotABranch            string
 	Actions                             Actions
 	Bisect                              Bisect
 	Log                                 Log
@@ -1478,13 +1480,15 @@ func EnglishTranslationSet() TranslationSet {
 		MarkedCommitMarker:                  "↑↑↑ Will rebase from here ↑↑↑",
 		PleaseGoToURL:                       "Please go to {{.url}}",
 		DisabledMenuItemPrefix:              "Disabled: ",
-		NoCommitSelected:                    "No commit selected",
 		NoCopiedCommits:                     "No copied commits",
 		QuickStartInteractiveRebase:         "Start interactive rebase",
 		QuickStartInteractiveRebaseTooltip:  "Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.\nIf you would instead like to start an interactive rebase from the selected commit, press `{{.editKey}}`.",
 		CannotQuickStartInteractiveRebase:   "Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `{{.editKey}}`.",
 		RangeSelectUp:                       "Range select up",
 		RangeSelectDown:                     "Range select down",
+		RangeSelectNotSupported:             "Action does not support range selection, please select a single item",
+		NoItemSelected:                      "No item selected",
+		SelectedItemIsNotABranch:            "Selected item is not a branch",
 		Actions: Actions{
 			// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
 			CheckoutCommit:                    "Checkout commit",
diff --git a/pkg/integration/tests/file/copy_menu.go b/pkg/integration/tests/file/copy_menu.go
index a1af13d7b..6e4f537af 100644
--- a/pkg/integration/tests/file/copy_menu.go
+++ b/pkg/integration/tests/file/copy_menu.go
@@ -29,10 +29,10 @@ var CopyMenu = NewIntegrationTest(NewIntegrationTestArgs{
 				t.ExpectPopup().Menu().
 					Title(Equals("Copy to clipboard")).
 					Select(Contains("File name")).
-					Tooltip(Equals("Disabled: Nothing to copy")).
+					Tooltip(Equals("Disabled: No item selected")).
 					Confirm().
 					Tap(func() {
-						t.ExpectToast(Equals("Disabled: Nothing to copy"))
+						t.ExpectToast(Equals("Disabled: No item selected"))
 					}).
 					Cancel()
 			})
diff --git a/pkg/integration/tests/ui/empty_menu.go b/pkg/integration/tests/ui/empty_menu.go
index d6b3b42f0..35c3d4560 100644
--- a/pkg/integration/tests/ui/empty_menu.go
+++ b/pkg/integration/tests/ui/empty_menu.go
@@ -22,7 +22,14 @@ var EmptyMenu = NewIntegrationTest(NewIntegrationTestArgs{
 			// a string that filters everything out
 			FilterOrSearch("ljasldkjaslkdjalskdjalsdjaslkd").
 			IsEmpty().
-			Press(keys.Universal.Select)
+			Press(keys.Universal.Select).
+			Tap(func() {
+				t.ExpectToast(Equals("Disabled: No item selected"))
+			}).
+			// escape the search
+			PressEscape().
+			// escape the view
+			PressEscape()
 
 		// back in the files view, selecting the non-existing menu item was a no-op
 		t.Views().Files().

From 1ea0c270df13f30d2e74cea7ec78ea3a62df1d82 Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Mon, 15 Jan 2024 20:12:53 +1100
Subject: [PATCH 10/11] Disable range-select in menu and suggestions view

We don't need it there so no need to enable it.

I'm leaving the disabled reason checks there, even though they're now redundant,
because they're only one-liners and they communicate intent.
---
 pkg/gui/context/list_context_trait.go  |  5 +++++
 pkg/gui/context/menu_context.go        |  5 +++++
 pkg/gui/context/suggestions_context.go |  5 +++++
 pkg/gui/controllers/list_controller.go | 17 +++++++++++++----
 pkg/gui/types/context.go               |  1 +
 5 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go
index d7315c9ec..eb738e332 100644
--- a/pkg/gui/context/list_context_trait.go
+++ b/pkg/gui/context/list_context_trait.go
@@ -118,3 +118,8 @@ func (self *ListContextTrait) IsItemVisible(item types.HasUrn) bool {
 	}
 	return false
 }
+
+// By default, list contexts supporta range select
+func (self *ListContextTrait) RangeSelectEnabled() bool {
+	return true
+}
diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go
index b5c1a3c20..131aa8665 100644
--- a/pkg/gui/context/menu_context.go
+++ b/pkg/gui/context/menu_context.go
@@ -195,3 +195,8 @@ func (self *MenuContext) OnMenuPress(selectedItem *types.MenuItem) error {
 
 	return nil
 }
+
+// There is currently no need to use range-select in a menu so we're disabling it.
+func (self *MenuContext) RangeSelectEnabled() bool {
+	return false
+}
diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go
index 0921a7329..30781fce1 100644
--- a/pkg/gui/context/suggestions_context.go
+++ b/pkg/gui/context/suggestions_context.go
@@ -90,3 +90,8 @@ func (self *SuggestionsContext) RefreshSuggestions() {
 		}
 	})
 }
+
+// There is currently no need to use range-select in the suggestions view so we're disabling it.
+func (self *SuggestionsContext) RangeSelectEnabled() bool {
+	return false
+}
diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go
index 9b7a740b5..dc876b3fc 100644
--- a/pkg/gui/controllers/list_controller.go
+++ b/pkg/gui/controllers/list_controller.go
@@ -183,7 +183,7 @@ func (self *ListController) isFocused() bool {
 }
 
 func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
-	return []*types.Binding{
+	bindings := []*types.Binding{
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine},
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine},
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine},
@@ -194,10 +194,19 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft},
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight},
 		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom},
-		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect},
-		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown},
-		{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp},
 	}
+
+	if self.context.RangeSelectEnabled() {
+		bindings = append(bindings,
+			[]*types.Binding{
+				{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect},
+				{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown},
+				{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp},
+			}...,
+		)
+	}
+
+	return bindings
 }
 
 func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
index a3f0ad24a..860a49588 100644
--- a/pkg/gui/types/context.go
+++ b/pkg/gui/types/context.go
@@ -144,6 +144,7 @@ type IListContext interface {
 
 	FocusLine()
 	IsListContext() // used for type switch
+	RangeSelectEnabled() bool
 }
 
 type IPatchExplorerContext interface {

From ab3004bcd56792a0caba4c6436567ca92c904d61 Mon Sep 17 00:00:00 2001
From: Jesse Duffield 
Date: Mon, 15 Jan 2024 20:49:47 +1100
Subject: [PATCH 11/11] Show unacknowledged toast message upon integration test
 failure

Often if a test fails and there's an unaknowledged toast message, that message will
explain why the test failed. Given that we don't display toast messages in
integration tests when they run (for reasons I can't recall right now), we need to
log it as part of the error message.
---
 pkg/gui/gui_driver.go | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go
index e7fa097d3..3421602e9 100644
--- a/pkg/gui/gui_driver.go
+++ b/pkg/gui/gui_driver.go
@@ -88,10 +88,18 @@ func (self *GuiDriver) ContextForView(viewName string) types.Context {
 
 func (self *GuiDriver) Fail(message string) {
 	currentView := self.gui.g.CurrentView()
+
+	// Check for unacknowledged toast: it may give us a hint as to why the test failed
+	toastMessage := ""
+	if t := self.NextToast(); t != nil {
+		toastMessage = fmt.Sprintf("Unacknowledged toast message: %s\n", *t)
+	}
+
 	fullMessage := fmt.Sprintf(
-		"%s\nFinal Lazygit state:\n%s\nUpon failure, focused view was '%s'.\nLog:\n%s", message,
+		"%s\nFinal Lazygit state:\n%s\nUpon failure, focused view was '%s'.\n%sLog:\n%s", message,
 		self.gui.g.Snapshot(),
 		currentView.Name(),
+		toastMessage,
 		strings.Join(self.gui.GuiLog, "\n"),
 	)