Repository: jesseduffield/lazygit (language: go)

Issue:
Support range select for adding multiple commit files to patch
Currently you can press space on a file in the commit files view (which you access by pressing enter on a commit) in order to add it to a custom patch. However, range-select is not supported.

We recently added range-select support for staging multiple files in the files view (see https://github.com/jesseduffield/lazygit/pull/3248), so it should be straight-forward to copy the approach for adding commit files to a patch.


Discussion / hints:
I wouldn't mind taking a shot at this one.
Sure thing @afhoffman, let me know if you need any pointers :)

Fix patch:
diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go
index 88f1becc5b4..0fedacd191b 100644
--- a/pkg/commands/patch/patch_builder.go
+++ b/pkg/commands/patch/patch_builder.go
@@ -80,13 +80,15 @@ func (p *PatchBuilder) PatchToApply(reverse bool) string {
 }
 
 func (p *PatchBuilder) addFileWhole(info *fileInfo) {
-	info.mode = WHOLE
-	lineCount := len(strings.Split(info.diff, "\n"))
-	// add every line index
-	// TODO: add tests and then use lo.Range to simplify
-	info.includedLineIndices = make([]int, lineCount)
-	for i := 0; i < lineCount; i++ {
-		info.includedLineIndices[i] = i
+	if info.mode != WHOLE {
+		info.mode = WHOLE
+		lineCount := len(strings.Split(info.diff, "\n"))
+		// add every line index
+		// TODO: add tests and then use lo.Range to simplify
+		info.includedLineIndices = make([]int, lineCount)
+		for i := 0; i < lineCount; i++ {
+			info.includedLineIndices[i] = i
+		}
 	}
 }
 
diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go
index e2ed4b3f0fe..647d6594bb7 100644
--- a/pkg/gui/controllers/commits_files_controller.go
+++ b/pkg/gui/controllers/commits_files_controller.go
@@ -1,6 +1,8 @@
 package controllers
 
 import (
+	"strings"
+
 	"github.com/jesseduffield/gocui"
 	"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -10,6 +12,7 @@ import (
 	"github.com/jesseduffield/lazygit/pkg/gui/filetree"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/jesseduffield/lazygit/pkg/utils"
+	"github.com/samber/lo"
 )
 
 type CommitFilesController struct {
@@ -76,8 +79,8 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []
 		},
 		{
 			Key:               opts.GetKey(opts.Config.Universal.Select),
-			Handler:           self.withItem(self.toggleForPatch),
-			GetDisabledReason: self.require(self.singleItemSelected()),
+			Handler:           self.withItems(self.toggleForPatch),
+			GetDisabledReason: self.require(self.itemsSelected()),
 			Description:       self.c.Tr.ToggleAddToPatch,
 			Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAddToPatchTooltip,
 				map[string]string{"doc": constants.Links.Docs.CustomPatchDemo},
@@ -240,7 +243,7 @@ func (self *CommitFilesController) openDiffTool(node *filetree.CommitFileNode) e
 	return err
 }
 
-func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) error {
+func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.CommitFileNode) error {
 	toggle := func() error {
 		return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error {
 			if !self.c.Git().Patch.PatchBuilder.Active() {
@@ -249,21 +252,29 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode)
 				}
 			}
 
-			// if there is any file that hasn't been fully added we'll fully add everything,
-			// otherwise we'll remove everything
-			adding := node.SomeFile(func(file *models.CommitFile) bool {
-				return self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Name, self.context().GetRef().RefName()) != patch.WHOLE
+			selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
+
+			// Find if any file in the selection is unselected or partially added
+			adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool {
+				return node.SomeFile(func(file *models.CommitFile) bool {
+					fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Name, self.context().GetRef().RefName())
+					return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
+				})
 			})
 
-			err := node.ForEachFile(func(file *models.CommitFile) error {
-				if adding {
-					return self.c.Git().Patch.PatchBuilder.AddFileWhole(file.Name)
-				} else {
-					return self.c.Git().Patch.PatchBuilder.RemoveFile(file.Name)
+			patchOperationFunction := self.c.Git().Patch.PatchBuilder.RemoveFile
+
+			if adding {
+				patchOperationFunction = self.c.Git().Patch.PatchBuilder.AddFileWhole
+			}
+
+			for _, node := range selectedNodes {
+				err := node.ForEachFile(func(file *models.CommitFile) error {
+					return patchOperationFunction(file.Name)
+				})
+				if err != nil {
+					return self.c.Error(err)
 				}
-			})
-			if err != nil {
-				return self.c.Error(err)
 			}
 
 			if self.c.Git().Patch.PatchBuilder.IsEmpty() {
@@ -290,7 +301,7 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode)
 
 func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error {
 	root := self.context().CommitFileTreeViewModel.GetRoot()
-	return self.toggleForPatch(root)
+	return self.toggleForPatch([]*filetree.CommitFileNode{root})
 }
 
 func (self *CommitFilesController) startPatchBuilder() error {
@@ -354,3 +365,23 @@ func (self *CommitFilesController) toggleTreeView() error {
 
 	return self.c.PostRefreshUpdate(self.context())
 }
+
+// NOTE: these functions are identical to those in files_controller.go (except for types) and
+// could also be cleaned up with some generics
+func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) []*filetree.CommitFileNode {
+	return lo.Filter(selectedNodes, func(node *filetree.CommitFileNode, _ int) bool {
+		return !isDescendentOfSelectedCommitFileNodes(node, selectedNodes)
+	})
+}
+
+func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool {
+	for _, selectedNode := range selectedNodes {
+		selectedNodePath := selectedNode.GetPath()
+		nodePath := node.GetPath()
+
+		if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath {
+			return true
+		}
+	}
+	return false
+}
diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go
index 99ed8d477e7..95cb1a14035 100644
--- a/pkg/gui/filetree/commit_file_tree_view_model.go
+++ b/pkg/gui/filetree/commit_file_tree_view_model.go
@@ -80,7 +80,18 @@ func (self *CommitFileTreeViewModel) GetSelectedItemId() string {
 }
 
 func (self *CommitFileTreeViewModel) GetSelectedItems() ([]*CommitFileNode, int, int) {
-	panic("Not implemented")
+	if self.Len() == 0 {
+		return nil, 0, 0
+	}
+
+	startIdx, endIdx := self.GetSelectionRange()
+
+	nodes := []*CommitFileNode{}
+	for i := startIdx; i <= endIdx; i++ {
+		nodes = append(nodes, self.Get(i))
+	}
+
+	return nodes, startIdx, endIdx
 }
 
 func (self *CommitFileTreeViewModel) GetSelectedItemIds() ([]string, int, int) {


Test patch:
diff --git a/pkg/integration/tests/patch_building/move_range_to_index.go b/pkg/integration/tests/patch_building/move_range_to_index.go
new file mode 100644
index 00000000000..2e7c95e9ac4
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_range_to_index.go
@@ -0,0 +1,70 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveRangeToIndex = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Apply a custom patch",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.CreateFileAndAdd("file1", "first line\n")
+		shell.Commit("first commit")
+
+		shell.UpdateFileAndAdd("file1", "first line\nsecond line\n")
+		shell.CreateFileAndAdd("file2", "file two content\n")
+		shell.CreateFileAndAdd("file3", "file three content\n")
+		shell.Commit("second commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("second commit").IsSelected(),
+				Contains("first commit"),
+			).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("M file1").IsSelected(),
+				Contains("A file2"),
+				Contains("A file3"),
+			).
+			Press(keys.Universal.ToggleRangeSelect).
+			NavigateToLine(Contains("file2")).
+			PressPrimaryAction()
+
+		t.Views().Information().Content(Contains("Building patch"))
+
+		t.Views().PatchBuildingSecondary().Content(Contains("second line"))
+		t.Views().PatchBuildingSecondary().Content(Contains("file two content"))
+
+		t.Common().SelectPatchOption(MatchesRegexp(`Move patch out into index$`))
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file3").IsSelected(),
+			).PressEscape()
+
+		t.Views().Files().
+			Focus().
+			Lines(
+				Contains("file1").IsSelected(),
+				Contains("file2"),
+			)
+
+		t.Views().Main().
+			Content(Contains("second line"))
+
+		t.Views().Files().Focus().NavigateToLine(Contains("file2"))
+
+		t.Views().Main().
+			Content(Contains("file two content"))
+	},
+})
diff --git a/pkg/integration/tests/patch_building/toggle_range.go b/pkg/integration/tests/patch_building/toggle_range.go
new file mode 100644
index 00000000000..6fd49adcf05
--- /dev/null
+++ b/pkg/integration/tests/patch_building/toggle_range.go
@@ -0,0 +1,107 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var ToggleRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Check multi select toggle logic",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.CreateDir("dir1")
+		shell.CreateFileAndAdd("dir1/file1-a", "d2f1 first line\nsecond line\nthird line\n")
+		shell.CreateFileAndAdd("dir1/file2-a", "d1f2 first line\n")
+		shell.CreateFileAndAdd("dir1/file3-a", "d1f3 first line\n")
+
+		shell.CreateDir("dir2")
+		shell.CreateFileAndAdd("dir2/file1-b", "d2f1 first line\nsecond line\nthird line\n")
+		shell.CreateFileAndAdd("dir2/file2-b", "d2f2 first line\n")
+		shell.CreateFileAndAdd("dir2/file3-b", "d2f3 first line\nsecond line\n")
+
+		shell.Commit("first commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("first commit").IsSelected(),
+			).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("▼ dir1").IsSelected(),
+				Contains("  A").Contains("file1-a"),
+				Contains("  A").Contains("file2-a"),
+				Contains("  A").Contains("file3-a"),
+				Contains("▼ dir2"),
+				Contains("  A").Contains("file1-b"),
+				Contains("  A").Contains("file2-b"),
+				Contains("  A").Contains("file3-b"),
+			).
+			NavigateToLine(Contains("file1-a")).
+			Press(keys.Universal.ToggleRangeSelect).
+			NavigateToLine(Contains("file3-a")).
+			PressPrimaryAction().
+			Lines(
+				Contains("▼ dir1"),
+				Contains("  ●").Contains("file1-a").IsSelected(),
+				Contains("  ●").Contains("file2-a").IsSelected(),
+				Contains("  ●").Contains("file3-a").IsSelected(),
+				Contains("▼ dir2"),
+				Contains("  A").Contains("file1-b"),
+				Contains("  A").Contains("file2-b"),
+				Contains("  A").Contains("file3-b"),
+			).
+			PressEscape().
+			NavigateToLine(Contains("file3-b")).
+			PressEnter()
+
+		t.Views().Main().IsFocused().
+			NavigateToLine(Contains("second line")).
+			PressPrimaryAction().
+			PressEscape()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("▼ dir1"),
+				Contains("  ●").Contains("file1-a"),
+				Contains("  ●").Contains("file2-a"),
+				Contains("  ●").Contains("file3-a"),
+				Contains("▼ dir2"),
+				Contains("  A").Contains("file1-b"),
+				Contains("  A").Contains("file2-b"),
+				Contains("  ◐").Contains("file3-b").IsSelected(),
+			).
+			NavigateToLine(Contains("dir1")).
+			Press(keys.Universal.ToggleRangeSelect).
+			NavigateToLine(Contains("dir2")).
+			PressPrimaryAction().
+			Lines(
+				Contains("▼ dir1").IsSelected(),
+				Contains("  ●").Contains("file1-a").IsSelected(),
+				Contains("  ●").Contains("file2-a").IsSelected(),
+				Contains("  ●").Contains("file3-a").IsSelected(),
+				Contains("▼ dir2").IsSelected(),
+				Contains("  ●").Contains("file1-b"),
+				Contains("  ●").Contains("file2-b"),
+				Contains("  ●").Contains("file3-b"),
+			).
+			PressPrimaryAction().
+			Lines(
+				Contains("▼ dir1").IsSelected(),
+				Contains("  A").Contains("file1-a").IsSelected(),
+				Contains("  A").Contains("file2-a").IsSelected(),
+				Contains("  A").Contains("file3-a").IsSelected(),
+				Contains("▼ dir2").IsSelected(),
+				Contains("  A").Contains("file1-b"),
+				Contains("  A").Contains("file2-b"),
+				Contains("  A").Contains("file3-b"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index f4693faef84..9c1f8b5fe76 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -194,6 +194,7 @@ var tests = []*components.IntegrationTest{
 	patch_building.Apply,
 	patch_building.ApplyInReverse,
 	patch_building.ApplyInReverseWithConflict,
+	patch_building.MoveRangeToIndex,
 	patch_building.MoveToEarlierCommit,
 	patch_building.MoveToEarlierCommitNoKeepEmpty,
 	patch_building.MoveToIndex,
@@ -209,6 +210,7 @@ var tests = []*components.IntegrationTest{
 	patch_building.SelectAllFiles,
 	patch_building.SpecificSelection,
 	patch_building.StartNewPatch,
+	patch_building.ToggleRange,
 	reflog.Checkout,
 	reflog.CherryPick,
 	reflog.DoNotShowBranchMarkersInReflogSubcommits,