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", 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 8b37c6aaa..dde60382a 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: @@ -201,6 +199,9 @@ keybinding: toggleWhitespaceInDiffView: '' increaseContextInDiffView: '}' decreaseContextInDiffView: '{' + toggleRangeSelect: 'v' + rangeSelectUp: '' + rangeSelectDown: '' status: checkForUpdate: 'u' recentRepos: '' @@ -248,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: '' @@ -263,8 +263,6 @@ keybinding: commitFiles: checkoutCommitFile: 'c' main: - toggleDragSelect: 'v' - toggleDragSelect-alt: 'V' toggleSelectHunk: 'a' pickBothHunks: 'b' submodules: @@ -389,15 +387,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: @@ -407,8 +403,6 @@ gui: theme: selectedLineBgColor: - reverse - selectedRangeBgColor: - - reverse ``` ## Custom Author Color 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/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/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 9ffd28746..04db47490 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 @@ -85,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 @@ -98,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 @@ -196,8 +198,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 +213,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
@@ -247,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
@@ -315,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 f2f26db5a..3f7f9fb01 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: 右スクロール
@@ -67,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
@@ -104,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
@@ -117,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
   /: 検索を開始
@@ -270,7 +271,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 +286,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 前のhunkを選択
   <right>: 次のhunkを選択
   v: 範囲選択を切り替え
-  V: 範囲選択を切り替え
   a: Hunk選択を切り替え
   <c-o>: 選択されたテキストをクリップボードにコピー
   o: ファイルを開く
@@ -347,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 36c045ba1..df5024617 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: 좌 스크롤
@@ -54,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>: 커밋 보기
@@ -85,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
@@ -141,7 +142,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 +157,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: 파일 닫기
@@ -269,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
@@ -282,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 bd78ff694..1ffd1ddcc 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
@@ -148,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
@@ -161,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
@@ -205,7 +207,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
@@ -225,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
@@ -266,7 +266,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
@@ -315,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 048dd490e..8b1061824 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
@@ -69,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
@@ -82,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
@@ -127,8 +129,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 +198,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
@@ -224,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
@@ -308,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 ba7af912f..b2904d335 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: Открыть файл
@@ -126,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>: Просмотреть коммиты
@@ -152,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: Установить/убрать автора коммита
@@ -165,8 +165,7 @@ _Связки клавиш_
   o: Открыть коммит в браузере
   n: Создать новую ветку с этого коммита
   g: Просмотреть параметры сброса
-  c: Скопировать отобранные коммит (cherry-pick)
-  C: Скопировать несколько отобранных коммитов (cherry-pick)
+  C: Скопировать отобранные коммит (cherry-pick)
   <c-t>: Open external diff tool (git difftool)
   <enter>: Просмотреть файлы выбранного элемента
   /: Найти
@@ -223,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 eaa7c725f..1496f8624 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: 向右滚动
@@ -54,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>: 查看提交
@@ -109,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>: 查看提交的文件
@@ -150,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
@@ -163,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>: 查看提交的文件
   /: 开始搜索
@@ -229,7 +229,6 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_
   <left>: 选择上一个区块
   <right>: 选择下一个区块
   v: 切换拖动选择
-  V: 切换拖动选择
   a: 切换选择区块
   <c-o>: 将选中文本复制到剪贴板
   o: 打开文件
@@ -274,7 +273,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..6243eafd4 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: 向右捲動
@@ -54,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>: 檢視提交
@@ -102,7 +104,6 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   <left>: 選擇上一段
   <right>: 選擇下一段
   v: 切換拖曳選擇
-  V: 切換拖曳選擇
   a: 切換選擇程式碼塊
   <c-o>: 複製所選文本至剪貼簿
   o: 開啟檔案
@@ -124,7 +125,6 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_
   <left>: 選擇上一段
   <right>: 選擇下一段
   v: 切換拖曳選擇
-  V: 切換拖曳選擇
   a: 切換選擇程式碼塊
   <c-o>: 複製所選文本至剪貼簿
   o: 開啟檔案
@@ -152,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>: 檢視所選項目的檔案
@@ -193,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: 設置/重設提交作者
@@ -206,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/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/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 602dc54bf..e9f739a1d 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
@@ -304,6 +301,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"`
@@ -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"`
@@ -441,11 +440,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 {
@@ -621,7 +618,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"},
@@ -704,6 +700,9 @@ func GetDefaultConfig() *UserConfig {
 				ScrollRight:                  "L",
 				GotoTop:                      "<",
 				GotoBottom:                   ">",
+				ToggleRangeSelect:            "v",
+				RangeSelectDown:              "",
+				RangeSelectUp:                "",
 				PrevBlock:                    "",
 				NextBlock:                    "",
 				PrevBlockAlt:                 "h",
@@ -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:                 "",
@@ -833,11 +831,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/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 ca3b3254f..eb738e332 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()
@@ -94,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{})
 }
 
@@ -110,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/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/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/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 54a9356ac..34f70e2c7 100644
--- a/pkg/gui/context/patch_explorer_context.go
+++ b/pkg/gui/context/patch_explorer_context.go
@@ -115,7 +115,10 @@ func (self *PatchExplorerContext) FocusSelection() {
 
 	_ = view.SetOriginY(newOriginY)
 
-	view.SetCursorY(state.GetSelectedLineIdx() - newOriginY)
+	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/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..30781fce1 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()
 }
@@ -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/context/traits/list_cursor.go b/pkg/gui/context/traits/list_cursor.go
index 9e86d5139..368485c05 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)
@@ -24,25 +45,110 @@ 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 {
 		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
+}
+
+// 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)
+	}
+
+	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/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/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go
index 551349466..386877b4d 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
@@ -21,87 +22,79 @@ 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.copy),
+			Handler:     self.withItem(self.copyRange),
 			Description: self.c.Tr.CherryPickCopy,
 		},
-		{
-			Key:         opts.GetKey(opts.Config.Commits.CherryPickCopyRange),
-			Handler:     self.checkSelected(self.copyRange),
-			Description: self.c.Tr.CherryPickCopyRange,
-		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.ResetCherryPick),
 			Handler:     self.c.Helpers().CherryPick.Reset,
 			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,
@@ -271,12 +264,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/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go
index 7cb36ef26..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,
@@ -265,7 +287,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
 			}
@@ -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 e0a38d463..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,
 		},
 	}
 }
@@ -165,7 +187,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
 				ShowBranchHeads:         false,
 			})
 		},
-		Key: 'v',
 	}
 
 	unsetUpstreamItem := &types.MenuItem{
@@ -309,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)
@@ -425,7 +446,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})
 }
 
@@ -562,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}
@@ -628,7 +649,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
@@ -659,7 +680,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
 						}
@@ -754,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 7776da5b3..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),
@@ -205,7 +217,7 @@ func (self *FilesController) GetOnRenderToMain() func() error {
 				}
 
 				if hasConflicts {
-					return self.c.Helpers().MergeConflicts.Render(false)
+					return self.c.Helpers().MergeConflicts.Render()
 				}
 			}
 
@@ -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/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/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/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/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/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),
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/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/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.go b/pkg/gui/controllers/list_controller.go
index 025561993..dc876b3fc 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)
@@ -125,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()()
@@ -148,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},
@@ -160,6 +195,18 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.
 		{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},
 	}
+
+	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/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 97151f4fe..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 {
@@ -459,7 +482,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
 					return c.Sha == selectedCommit.Sha
 				})
 				if ok {
-					self.context().SetSelectedLineIdx(index)
+					self.context().SetSelection(index)
 				}
 			}})
 	})
@@ -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 86f49489c..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,
 	}
 }
 
@@ -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/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go
index 68179d158..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,
 	}
 }
 
@@ -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/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go
index 6f193cf2d..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),
@@ -75,14 +87,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),
@@ -182,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)
 
@@ -195,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/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/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 04afd6415..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)
 }
@@ -132,7 +130,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..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 {
@@ -106,7 +112,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
@@ -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 46cd6cb69..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,
@@ -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/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go
index 5d74e10af..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
 }
@@ -189,7 +185,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/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 7143a8805..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,14 +68,10 @@ 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
 
-	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/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 dcbef4d2c..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,
 		},
 	}
 
@@ -210,22 +220,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) })
-}
-
-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()
+	return self.c.Helpers().Tags.OpenCreateTagPrompt("", func() {
+		self.context().SetSelection(0)
+	})
 }
 
 func (self *TagsController) context() *context.TagsContext {
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/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 547b62b91..2364087d3 100644
--- a/pkg/gui/filetree/file_tree_view_model.go
+++ b/pkg/gui/filetree/file_tree_view_model.go
@@ -81,11 +81,11 @@ func (self *FileTreeViewModel) SetTree() {
 		newNodes := self.GetAllItems()
 		newIdx := self.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], newNodes)
 		if newIdx != -1 && newIdx != prevSelectedLineIdx {
-			self.SetSelectedLineIdx(newIdx)
+			self.SetSelection(newIdx)
 		}
 	}
 
-	self.RefreshSelectedIdx()
+	self.ClampSelection()
 }
 
 // Let's try to find our file again and move the cursor to that.
@@ -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/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/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/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"),
 	)
 
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/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/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/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/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 1c82d59cb..ccd30d03f 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)
@@ -196,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,
 	})
 }
@@ -226,3 +266,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
+}
diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go
index 82f03c70f..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 {
@@ -163,6 +164,8 @@ type IPatchExplorerContext interface {
 
 type IViewTrait interface {
 	FocusPoint(yIdx int)
+	SetRangeSelectStart(yIdx int)
+	CancelRangeSelect()
 	SetViewPortContent(content string)
 	SetContent(content string)
 	SetFooter(value string)
@@ -222,12 +225,21 @@ type IList interface {
 type IListCursor interface {
 	GetSelectedLineIdx() int
 	SetSelectedLineIdx(value int)
+	SetSelection(value int)
 	MoveSelectedLine(delta int)
-	RefreshSelectedIdx()
+	ClampSelection()
+	CancelRangeSelect()
+	GetRangeStartIdx() (int, bool)
+	GetSelectionRange() (int, int)
+	IsSelectingRange() bool
+	AreMultipleItemsSelected() bool
+	ToggleStickyRange()
+	ExpandNonStickyRange(int)
 }
 
 type IListPanelState interface {
 	SetSelectedLineIdx(int)
+	SetSelection(int)
 	GetSelectedLineIdx() int
 }
 
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/i18n/chinese.go b/pkg/i18n/chinese.go
index 234d470db..8386bce1e 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:                  `切换到其他面板`,
@@ -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 7abfaa437..1e2eaa689 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`,
@@ -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 8886217c3..35e7dd687 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
@@ -252,7 +251,6 @@ type TranslationSet struct {
 	RewordNotSupported                  string
 	ChangingThisActionIsNotAllowed      string
 	CherryPickCopy                      string
-	CherryPickCopyRange                 string
 	PasteCommits                        string
 	SureCherryPick                      string
 	CherryPick                          string
@@ -646,11 +644,16 @@ type TranslationSet struct {
 	MarkedCommitMarker                  string
 	PleaseGoToURL                       string
 	DisabledMenuItemPrefix              string
-	NoCommitSelected                    string
 	NoCopiedCommits                     string
 	QuickStartInteractiveRebase         string
 	QuickStartInteractiveRebaseTooltip  string
 	CannotQuickStartInteractiveRebase   string
+	ToggleRangeSelect                   string
+	RangeSelectUp                       string
+	RangeSelectDown                     string
+	RangeSelectNotSupported             string
+	NoItemSelected                      string
+	SelectedItemIsNotABranch            string
 	Actions                             Actions
 	Bisect                              Bisect
 	Log                                 Log
@@ -1033,7 +1036,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`,
@@ -1088,7 +1091,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",
@@ -1478,11 +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/i18n/japanese.go b/pkg/i18n/japanese.go
index 089c95ed6..3da17b097 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:      `パネルを切り替え`,
@@ -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 d6b7793a0..3c4d0ceab 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:                  `패널 전환`,
@@ -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 0e63841b9..1522a0f1c 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:                            `Изменить эту часть`,
@@ -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 703c6667c..b9519bfcd 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:                            `編輯程式碼塊`,
@@ -274,7 +274,6 @@ func traditionalChineseTranslationSet() TranslationSet {
 		RewordNotSupported:                  "在互動變基期間改寫提交目前不支持",
 		ChangingThisActionIsNotAllowed:      "不允許更改此類變基待辦事項",
 		CherryPickCopy:                      "複製提交 (揀選)",
-		CherryPickCopyRange:                 "複製提交範圍 (揀選)",
 		PasteCommits:                        "貼上提交 (揀選)",
 		SureCherryPick:                      "你確定要將複製的提交揀選到此分支嗎?",
 		CherryPick:                          "揀選 (Cherry-pick)",
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/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/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/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/patch_building/specific_selection.go b/pkg/integration/tests/patch_building/specific_selection.go
index cfb7e64f1..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,17 +100,12 @@ 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"),
 			).
 			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/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index a0e58d6fb..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,
@@ -260,6 +261,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/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().
diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go
new file mode 100644
index 000000000..4885c7cb4
--- /dev/null
+++ b/pkg/integration/tests/ui/range_select.go
@@ -0,0 +1,168 @@
+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 '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
+
+// 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"),
+				).
+				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"),
+				)
+		}
+
+		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/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..d5131c0ff 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"
@@ -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",
@@ -1178,14 +1174,6 @@
         },
         "main": {
           "properties": {
-            "toggleDragSelect": {
-              "type": "string",
-              "default": "v"
-            },
-            "toggleDragSelect-alt": {
-              "type": "string",
-              "default": "V"
-            },
             "toggleSelectHunk": {
               "type": "string",
               "default": "a"
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
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